diff --git a/.changeset/big-mice-push.md b/.changeset/big-mice-push.md new file mode 100644 index 0000000000..4f83014128 --- /dev/null +++ b/.changeset/big-mice-push.md @@ -0,0 +1,5 @@ +--- +'houdini': minor +--- + +Add support for marking data as stale diff --git a/e2e/sveltekit/src/routes/stores/metadata/spec.ts b/e2e/sveltekit/src/routes/stores/metadata/spec.ts index cc9963c8bf..aab307405c 100644 --- a/e2e/sveltekit/src/routes/stores/metadata/spec.ts +++ b/e2e/sveltekit/src/routes/stores/metadata/spec.ts @@ -16,7 +16,7 @@ test.describe('Metadata Page', () => { await goto_expect_n_gql(page, routes.Stores_Metadata, 1); expect(displayed).toBe( - '{"fetching":false,"variables":{},"data":{"session":"1234-Houdini-Token-5678"},"errors":null,"partial":false,"source":"network"}' + '{"fetching":false,"variables":{},"data":{"session":"1234-Houdini-Token-5678"},"errors":null,"partial":false,"stale":false,"source":"network"}' ); //Click the button @@ -24,7 +24,7 @@ test.describe('Metadata Page', () => { await expect_1_gql(page, 'button[id=mutate]'); expect(displayed).toBe( - '{"fetching":false,"variables":{"id":"5","name":"Hello!"},"data":{"updateUser":{"id":"list-store-user-subunsub:5","name":"Hello!"}},"errors":null,"partial":false,"source":"network"}' + '{"fetching":false,"variables":{"id":"5","name":"Hello!"},"data":{"updateUser":{"id":"list-store-user-subunsub:5","name":"Hello!"}},"errors":null,"partial":false,"stale":false,"source":"network"}' ); }); }); diff --git a/e2e/sveltekit/src/routes/stores/mutation/spec.ts b/e2e/sveltekit/src/routes/stores/mutation/spec.ts index 3862aba7e5..fb6e208d02 100644 --- a/e2e/sveltekit/src/routes/stores/mutation/spec.ts +++ b/e2e/sveltekit/src/routes/stores/mutation/spec.ts @@ -13,6 +13,7 @@ test.describe('Mutation Page', () => { fetching: false, variables: null, partial: false, + stale: false, source: null }; await expectToBe(page, stry(defaultStoreValues) ?? '', '[id="store-value"]'); diff --git a/e2e/sveltekit/src/routes/stores/prefetch-[userId]/spec.ts b/e2e/sveltekit/src/routes/stores/prefetch-[userId]/spec.ts index c1361eadb3..825b761682 100644 --- a/e2e/sveltekit/src/routes/stores/prefetch-[userId]/spec.ts +++ b/e2e/sveltekit/src/routes/stores/prefetch-[userId]/spec.ts @@ -7,7 +7,7 @@ test.describe('prefetch-[userId] Page', () => { await goto(page, routes.Stores_Prefetch_UserId_2); const dataDisplayedSSR = - '{"data":{"user":{"id":"store-user-query:2","name":"Samuel Jackson"}},"errors":null,"fetching":false,"partial":false,"source":"network","variables":{"id":"2"}}'; + '{"data":{"user":{"id":"store-user-query:2","name":"Samuel Jackson"}},"errors":null,"fetching":false,"partial":false,"source":"network","stale":false,"variables":{"id":"2"}}'; // The page should have the right data directly await expectToBe(page, dataDisplayedSSR); diff --git a/packages/houdini/src/runtime/cache/cache.ts b/packages/houdini/src/runtime/cache/cache.ts index 622df9a8fc..23fbf47a8a 100644 --- a/packages/houdini/src/runtime/cache/cache.ts +++ b/packages/houdini/src/runtime/cache/cache.ts @@ -1,5 +1,6 @@ -import { defaultConfigValues, computeID, keyFieldsForType } from '../lib/config' +import { computeKey } from '../lib' import type { ConfigFile } from '../lib/config' +import { computeID, defaultConfigValues, keyFieldsForType } from '../lib/config' import { deepEquals } from '../lib/deepEquals' import { getFieldsForType } from '../lib/selection' import type { @@ -12,10 +13,11 @@ import { GarbageCollector } from './gc' import type { ListCollection } from './lists' import { ListManager } from './lists' import { SchemaManager } from './schema' +import { StaleManager } from './staleManager' import type { Layer, LayerID } from './storage' import { InMemoryStorage } from './storage' import { evaluateKey, flattenList } from './stuff' -import { type FieldSelection, InMemorySubscriptions } from './subscription' +import { InMemorySubscriptions, type FieldSelection } from './subscription' export class Cache { // the internal implementation for a lot of the cache's methods are moved into @@ -30,6 +32,7 @@ export class Cache { subscriptions: new InMemorySubscriptions(this), lists: new ListManager(this, rootID), lifetimes: new GarbageCollector(this), + staleManager: new StaleManager(this), schema: new SchemaManager(this), }) @@ -53,6 +56,7 @@ export class Cache { applyUpdates?: string[] notifySubscribers?: SubscriptionSpec[] forceNotify?: boolean + forceStale?: boolean }): SubscriptionSpec[] { // find the correct layer const layer = layerID @@ -88,15 +92,16 @@ export class Cache { // reconstruct an object with the fields/relations specified by a selection read(...args: Parameters) { - const { data, partial, hasData } = this._internal_unstable.getSelection(...args) + const { data, partial, stale, hasData } = this._internal_unstable.getSelection(...args) if (!hasData) { - return { data: null, partial: false } + return { data: null, partial: false, stale: false } } return { data, partial, + stale, } } @@ -153,6 +158,33 @@ export class Cache { setConfig(config: ConfigFile) { this._internal_unstable.setConfig(config) } + + markTypeStale(type?: string, options: { field?: string; when?: {} } = {}): void { + if (!type) { + this._internal_unstable.staleManager.markAllStale() + } else if (!options.field) { + this._internal_unstable.staleManager.markTypeStale(type) + } else { + this._internal_unstable.staleManager.markTypeFieldStale( + type, + options.field, + options.when + ) + } + } + + markRecordStale(id: string, options: { field?: string; when?: {} }) { + if (options.field) { + const key = computeKey({ field: options.field, args: options.when ?? {} }) + this._internal_unstable.staleManager.markFieldStale(id, key) + } else { + this._internal_unstable.staleManager.markRecordStale(id) + } + } + + getFieldTime(id: string, field: string) { + return this._internal_unstable.staleManager.getFieldTime(id, field) + } } class CacheInternal { @@ -171,6 +203,7 @@ class CacheInternal { lists: ListManager cache: Cache lifetimes: GarbageCollector + staleManager: StaleManager schema: SchemaManager constructor({ @@ -179,6 +212,7 @@ class CacheInternal { lists, cache, lifetimes, + staleManager, schema, }: { storage: InMemoryStorage @@ -186,6 +220,7 @@ class CacheInternal { lists: ListManager cache: Cache lifetimes: GarbageCollector + staleManager: StaleManager schema: SchemaManager }) { this.storage = storage @@ -193,6 +228,7 @@ class CacheInternal { this.lists = lists this.cache = cache this.lifetimes = lifetimes + this.staleManager = staleManager this.schema = schema // the cache should always be disabled on the server, unless we're testing @@ -219,6 +255,7 @@ class CacheInternal { layer, toNotify = [], forceNotify, + forceStale, }: { data: { [key: string]: GraphQLValue } selection: SubscriptionSelection @@ -229,6 +266,7 @@ class CacheInternal { toNotify?: FieldSelection[] applyUpdates?: string[] forceNotify?: boolean + forceStale?: boolean }): FieldSelection[] { // if the cache is disabled, dont do anything if (this._disabled) { @@ -291,6 +329,13 @@ class CacheInternal { // if we are writing to the display layer we need to refresh the lifetime of the value if (displayLayer) { this.lifetimes.resetLifetime(parent, key) + + // update the stale status + if (forceStale) { + this.staleManager.markFieldStale(parent, key) + } else { + this.staleManager.setFieldTimeToNow(parent, key) + } } // any scalar is defined as a field with no selection @@ -726,10 +771,10 @@ class CacheInternal { parent?: string variables?: {} stepsFromConnection?: number | null - }): { data: GraphQLObject | null; partial: boolean; hasData: boolean } { + }): { data: GraphQLObject | null; partial: boolean; stale: boolean; hasData: boolean } { // we could be asking for values of null if (parent === null) { - return { data: null, partial: false, hasData: true } + return { data: null, partial: false, stale: false, hasData: true } } const target = {} as GraphQLObject @@ -743,6 +788,9 @@ class CacheInternal { // that happens after we process every field to determine if its a partial null let cascadeNull = false + // Check if we have at least one stale data + let stale = false + // if we have abstract fields, grab the __typename and include them in the list const typename = this.storage.get(parent, '__typename').value as string // collect all of the fields that we need to write @@ -758,6 +806,12 @@ class CacheInternal { // look up the value in our store const { value } = this.storage.get(parent, key) + // If we have an explicite null, that mean that it's stale and the we should do a network call + const dt_field = this.staleManager.getFieldTime(parent, key) + if (dt_field === null) { + stale = true + } + // in order to avoid falsey identifying the `cursor` field of a connection edge // as missing non-nullable data (and therefor cascading null to the response) we need to // count the number of steps since we saw a connection field and if we are at the @@ -834,6 +888,10 @@ class CacheInternal { partial = true } + if (listValue.stale) { + stale = true + } + if (listValue.hasData || value.length === 0) { hasData = true } @@ -857,6 +915,10 @@ class CacheInternal { partial = true } + if (objectFields.stale) { + stale = true + } + if (objectFields.hasData) { hasData = true } @@ -874,6 +936,7 @@ class CacheInternal { // our value is considered true if there is some data but not everything // has a full value partial: hasData && partial, + stale: hasData && stale, hasData, } } @@ -916,12 +979,13 @@ class CacheInternal { variables?: {} linkedList: LinkedList stepsFromConnection: number | null - }): { data: LinkedList; partial: boolean; hasData: boolean } { + }): { data: LinkedList; partial: boolean; stale: boolean; hasData: boolean } { // the linked list could be a deeply nested thing, we need to call getData for each record // we can't mutate the lists because that would change the id references in the listLinks map // to the corresponding record. can't have that now, can we? const result: LinkedList = [] let partialData = false + let stale = false let hasValues = false for (const entry of linkedList) { @@ -947,7 +1011,12 @@ class CacheInternal { } // look up the data for the record - const { data, partial, hasData } = this.getSelection({ + const { + data, + partial, + stale: local_stale, + hasData, + } = this.getSelection({ parent: entry, selection: fields, variables, @@ -960,6 +1029,10 @@ class CacheInternal { partialData = true } + if (local_stale) { + stale = true + } + if (hasData) { hasValues = true } @@ -968,6 +1041,7 @@ class CacheInternal { return { data: result, partial: partialData, + stale, hasData: hasValues, } } diff --git a/packages/houdini/src/runtime/cache/gc.ts b/packages/houdini/src/runtime/cache/gc.ts index 0eaf860f32..6ecd44ecef 100644 --- a/packages/houdini/src/runtime/cache/gc.ts +++ b/packages/houdini/src/runtime/cache/gc.ts @@ -25,6 +25,10 @@ export class GarbageCollector { } tick() { + // get the current time of the tick + const dt_tick = Date.now().valueOf() + const config_max_time = this.cache._internal_unstable.config.defaultLifetime + // look at every field of every record we know about for (const [id, fieldMap] of this.lifetimes.entries()) { for (const [field, lifetime] of fieldMap.entries()) { @@ -33,6 +37,9 @@ export class GarbageCollector { continue } + // --- ----------------- --- + // --- Part 1 : lifetime --- + // --- ----------------- --- // there are no active subscriptions for this field, increment the lifetime count fieldMap.set(field, lifetime + 1) @@ -49,6 +56,23 @@ export class GarbageCollector { if ([...fieldMap.keys()].length === 0) { this.lifetimes.delete(id) } + + // remove the field from the stale manager + this.cache._internal_unstable.staleManager.delete(id, field) + } + + // --- ------------------- --- + // --- Part 2 : fieldTimes --- + // --- ------------------- --- + if (config_max_time && config_max_time > 0) { + // if the field is older than x... mark it as stale + const dt_valueOf = this.cache.getFieldTime(id, field) + + // if we have no dt_valueOf, it's already stale + // check if more than the max time has passed since it was marked stale + if (dt_valueOf && dt_tick - dt_valueOf > config_max_time) { + this.cache._internal_unstable.staleManager.markFieldStale(id, field) + } } } } diff --git a/packages/houdini/src/runtime/cache/staleManager.ts b/packages/houdini/src/runtime/cache/staleManager.ts new file mode 100644 index 0000000000..e8b93d7c19 --- /dev/null +++ b/packages/houdini/src/runtime/cache/staleManager.ts @@ -0,0 +1,111 @@ +import { computeKey } from '../lib' +import type { Cache } from './cache' + +export class StaleManager { + cache: Cache + + // id { "User:1" "_ROOT_" + // field { "id" "viewer" + // number | undefined | null + // } + // } + + // number => data ok (not stale!) + // undefined => no data (not stale!) + // null => data stale (stale) + + // nulls mean that the value is stale, and the number is the time that the value was set + private fieldsTime: Map> = new Map() + + constructor(cache: Cache) { + this.cache = cache + } + + #initMapId = (id: string) => { + if (!this.fieldsTime.get(id)) { + this.fieldsTime.set(id, new Map()) + } + } + + /** + * get the FieldTime info + * @param id User:1 + * @param field firstName + */ + getFieldTime(id: string, field: string): number | undefined | null { + return this.fieldsTime.get(id)?.get(field) + } + + /** + * set the date to a field + * @param id User:1 + * @param field firstName + */ + setFieldTimeToNow(id: string, field: string): void { + this.#initMapId(id) + this.fieldsTime.get(id)?.set(field, new Date().valueOf()) + } + + /** + * set null to a field (stale) + * @param id User:1 + * @param field firstName + */ + markFieldStale(id: string, field: string): void { + this.#initMapId(id) + this.fieldsTime.get(id)?.set(field, null) + } + + markAllStale(): void { + for (const [id, fieldMap] of this.fieldsTime.entries()) { + for (const [field] of fieldMap.entries()) { + this.markFieldStale(id, field) + } + } + } + + markRecordStale(id: string): void { + const fieldsTimeOfType = this.fieldsTime.get(id) + if (fieldsTimeOfType) { + for (const [field] of fieldsTimeOfType.entries()) { + this.markFieldStale(id, field) + } + } + } + + markTypeStale(type: string): void { + for (const [id, fieldMap] of this.fieldsTime.entries()) { + // if starts lile `User:` (it will catch `User:1` for example) + if (id.startsWith(`${type}:`)) { + for (const [field] of fieldMap.entries()) { + this.markFieldStale(id, field) + } + } + } + } + + markTypeFieldStale(type: string, field: string, when?: {}): void { + const key = computeKey({ field, args: when }) + + for (const [id, fieldMap] of this.fieldsTime.entries()) { + // if starts with `User:` (it will catch `User:1` for example) + if (id.startsWith(`${type}:`)) { + for (const local_field of fieldMap.keys()) { + if (local_field === key) { + this.markFieldStale(id, field) + } + } + } + } + } + + // clean up the stale manager + delete(id: string, field: string) { + if (this.fieldsTime.has(id)) { + this.fieldsTime.get(id)?.delete(field) + if (this.fieldsTime.get(id)?.size === 0) { + this.fieldsTime.delete(id) + } + } + } +} diff --git a/packages/houdini/src/runtime/cache/subscription.ts b/packages/houdini/src/runtime/cache/subscription.ts index 6b8993dfd4..09365690d5 100644 --- a/packages/houdini/src/runtime/cache/subscription.ts +++ b/packages/houdini/src/runtime/cache/subscription.ts @@ -60,6 +60,7 @@ export class InMemorySubscriptions { id: parent, key, selection: [spec, targetSelection], + type, }) if (list) { @@ -108,10 +109,12 @@ export class InMemorySubscriptions { id, key, selection, + type, }: { id: string key: string selection: FieldSelection + type: string }) { const spec = selection[0] // if we haven't seen the id or field before, create a list we can add to @@ -224,6 +227,7 @@ export class InMemorySubscriptions { id: parent, key, selection: [spec, fieldSelection], + type: linkedType, }) if (list) { diff --git a/packages/houdini/src/runtime/cache/tests/list.test.ts b/packages/houdini/src/runtime/cache/tests/list.test.ts index 0078dd5d1d..2d46c90188 100644 --- a/packages/houdini/src/runtime/cache/tests/list.test.ts +++ b/packages/houdini/src/runtime/cache/tests/list.test.ts @@ -955,6 +955,7 @@ test("prepending update doesn't overwrite endCursor and hasNext Page", function // make sure that the data looks good expect(cache.read({ selection })).toEqual({ partial: false, + stale: false, data: { viewer: { id: '1', @@ -1153,6 +1154,7 @@ test("append update doesn't overwrite startCursor and hasPrevious Page", functio // make sure that the data looks good expect(cache.read({ selection })).toEqual({ partial: false, + stale: false, data: { viewer: { id: '1', diff --git a/packages/houdini/src/runtime/cache/tests/readwrite.test.ts b/packages/houdini/src/runtime/cache/tests/readwrite.test.ts index 991630b9d2..4c863d1f6f 100644 --- a/packages/houdini/src/runtime/cache/tests/readwrite.test.ts +++ b/packages/houdini/src/runtime/cache/tests/readwrite.test.ts @@ -1277,6 +1277,7 @@ test('null-value cascade from object value', function () { }) ).toEqual({ partial: true, + stale: false, data: { viewer: null, }, @@ -1310,6 +1311,7 @@ test('null-value cascade from object value', function () { }) ).toEqual({ partial: true, + stale: false, data: { viewer: { id: '1', @@ -1375,6 +1377,7 @@ test('null-value cascade to root', function () { ).toEqual({ data: null, partial: true, + stale: false, }) // read the data as if the nested value is not required (parent should be null) @@ -1459,6 +1462,7 @@ test('must have a single value in order to use partial data', function () { }) ).toEqual({ partial: false, + stale: false, data: null, }) @@ -1488,6 +1492,7 @@ test('must have a single value in order to use partial data', function () { }) ).toEqual({ partial: true, + stale: false, data: { viewer: null, }, @@ -1576,6 +1581,7 @@ test('reading an empty list counts as data', function () { }) ).toEqual({ partial: false, + stale: false, data: { viewer: { friends: [], diff --git a/packages/houdini/src/runtime/client/documentStore.test.ts b/packages/houdini/src/runtime/client/documentStore.test.ts index f804f692a0..c1b0e36d3b 100644 --- a/packages/houdini/src/runtime/client/documentStore.test.ts +++ b/packages/houdini/src/runtime/client/documentStore.test.ts @@ -115,6 +115,7 @@ test('middleware pipeline happy path', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -180,6 +181,7 @@ test('middleware pipeline happy path', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -214,6 +216,7 @@ test('terminate short-circuits pipeline', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -225,6 +228,7 @@ test('terminate short-circuits pipeline', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -282,6 +286,7 @@ test('uneven lists phases', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -296,6 +301,7 @@ test('uneven lists phases', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -332,6 +338,7 @@ test('can call resolve multiple times to set multiple values', async function () errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -341,6 +348,7 @@ test('can call resolve multiple times to set multiple values', async function () errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -364,6 +372,7 @@ test('can call resolve multiple times to set multiple values', async function () errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -372,13 +381,14 @@ test('can call resolve multiple times to set multiple values', async function () errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) expect(fn).toHaveBeenNthCalledWith(3, { fetching: true, partial: false, - + stale: false, data: { hello: 'another-world' }, errors: [], source: DataSource.Cache, @@ -427,6 +437,7 @@ test('middlewares can set fetch params', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -456,6 +467,7 @@ test('exit can replay a pipeline', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -472,6 +484,7 @@ test('exit can replay a pipeline', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -483,6 +496,7 @@ test('exit can replay a pipeline', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -498,6 +512,7 @@ test('exit can replay a pipeline', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -536,6 +551,7 @@ test('plugins can update variables', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -574,6 +590,7 @@ test('can detect changed variables from inputs', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -634,6 +651,7 @@ test('can pass new variables in a spread', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -676,6 +694,7 @@ test('can update variables and then check if they were updated', async function errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -726,6 +745,7 @@ test('multiple new variables from inside plugin', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: ctx.variables!, }) @@ -773,6 +793,7 @@ test('can set observer state from hook', async function () { errors: null, fetching: true, partial: false, + stale: false, source: DataSource.Network, variables: null, }) @@ -796,6 +817,7 @@ test('can set observer state from hook', async function () { errors: null, fetching: true, partial: false, + stale: false, source: null, variables: null, }) @@ -806,6 +828,7 @@ test('can set observer state from hook', async function () { errors: null, fetching: true, partial: false, + stale: false, source: DataSource.Network, variables: null, }) @@ -860,6 +883,7 @@ test("sending a setup message doesn't trigger the network steps", async function errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -902,6 +926,7 @@ test('in a query, if fetching is set to false, return with false', async functio errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -924,6 +949,7 @@ test('in a query, if fetching is set to false, return with false', async functio errors: null, fetching: false, partial: false, + stale: false, source: null, variables: null, }) @@ -937,6 +963,7 @@ test('in a mutation, fetching should be false', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -959,6 +986,7 @@ test('in a mutation, fetching should be false', async function () { errors: null, fetching: false, partial: false, + stale: false, source: null, variables: null, }) @@ -1047,6 +1075,7 @@ test('throw hooks can resolve the plugin instead', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -1073,6 +1102,7 @@ test('throw hooks can resolve the plugin instead', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, }) @@ -1088,6 +1118,7 @@ test('throw hooks can replay the plugin instead', async function () { errors: [], fetching: true, partial: false, + stale: false, source: DataSource.Cache, variables: null, } diff --git a/packages/houdini/src/runtime/client/documentStore.ts b/packages/houdini/src/runtime/client/documentStore.ts index db78ae97e8..55754a0863 100644 --- a/packages/houdini/src/runtime/client/documentStore.ts +++ b/packages/houdini/src/runtime/client/documentStore.ts @@ -68,6 +68,7 @@ export class DocumentStore< data: initialValue ?? null, errors: null, partial: false, + stale: false, source: null, fetching, variables: null, diff --git a/packages/houdini/src/runtime/client/plugins/cache.test.ts b/packages/houdini/src/runtime/client/plugins/cache.test.ts index 4e9dda0c54..742d466d21 100644 --- a/packages/houdini/src/runtime/client/plugins/cache.test.ts +++ b/packages/houdini/src/runtime/client/plugins/cache.test.ts @@ -39,6 +39,7 @@ test('NetworkOnly', async function () { viewer: { id: '1', firstName: 'bob', + __typename: 'User', }, }, errors: null, @@ -46,6 +47,7 @@ test('NetworkOnly', async function () { variables: null, source: 'network', partial: false, + stale: false, }) expect(ret2).toEqual({ @@ -53,6 +55,7 @@ test('NetworkOnly', async function () { viewer: { id: '1', firstName: 'bob', + __typename: 'User', }, }, errors: null, @@ -60,6 +63,7 @@ test('NetworkOnly', async function () { variables: null, source: 'network', partial: false, + stale: false, }) }) @@ -84,6 +88,7 @@ test('CacheOrNetwork', async function () { viewer: { id: '1', firstName: 'bob', + __typename: 'User', }, }, errors: null, @@ -91,6 +96,7 @@ test('CacheOrNetwork', async function () { variables: null, source: 'network', partial: false, + stale: false, }) expect(ret2).toEqual({ @@ -98,6 +104,7 @@ test('CacheOrNetwork', async function () { viewer: { id: '1', firstName: 'bob', + __typename: 'User', }, }, errors: null, @@ -105,6 +112,7 @@ test('CacheOrNetwork', async function () { variables: {}, source: 'cache', partial: false, + stale: false, }) }) @@ -130,6 +138,7 @@ test('CacheOnly', async function () { variables: {}, source: 'cache', partial: false, + stale: false, }) const ret2 = await store.send({ policy: CachePolicy.CacheOrNetwork }) @@ -140,6 +149,7 @@ test('CacheOnly', async function () { viewer: { id: '1', firstName: 'bob', + __typename: 'User', }, }, errors: null, @@ -147,6 +157,7 @@ test('CacheOnly', async function () { variables: null, source: 'network', partial: false, + stale: false, }) const ret3 = await store.send({ policy: CachePolicy.CacheOnly }) @@ -157,6 +168,7 @@ test('CacheOnly', async function () { viewer: { id: '1', firstName: 'bob', + __typename: 'User', }, }, errors: null, @@ -164,6 +176,111 @@ test('CacheOnly', async function () { variables: {}, source: 'cache', partial: false, + stale: false, + }) +}) + +test('stale', async function () { + const setFetching = vi.fn() + const fn = vi.fn() + + const cache = new Cache(config) + + const store = createStore([ + cachePolicyPlugin({ + enabled: true, + setFetching, + cache, + }), + fakeFetch({}), + ]) + + store.subscribe(fn) + + const ret1 = await store.send({ policy: CachePolicy.CacheOrNetwork }) + + // Final return + expect(ret1).toEqual({ + data: { + viewer: { + __typename: 'User', + firstName: 'bob', + id: '1', + }, + }, + errors: null, + fetching: false, + partial: false, + source: 'network', + stale: false, + variables: null, + }) + + // intermediate returns + expect(fn).toHaveBeenNthCalledWith(1, { + data: null, + errors: null, + fetching: true, + partial: false, + source: null, + stale: false, + variables: null, + }) + + // mark stale + cache.markTypeStale('User') + + const ret2 = await store.send({ policy: CachePolicy.CacheOrNetwork }) + + // First final return with stale: true + expect(ret2).toEqual({ + data: { + viewer: { + __typename: 'User', + firstName: 'bob', + id: '1', + }, + }, + errors: null, + fetching: false, + partial: false, + source: 'cache', + stale: true, + variables: {}, + }) + + // intermediate returns + expect(fn).toHaveBeenNthCalledWith(3, { + data: { + viewer: { + __typename: 'User', + firstName: 'bob', + id: '1', + }, + }, + errors: null, + fetching: false, + partial: false, + source: 'cache', + stale: true, + variables: {}, + }) + + // Doing a real network call in the end and returning the new data & stale false + expect(fn).toHaveBeenNthCalledWith(4, { + data: { + viewer: { + __typename: 'User', + firstName: 'bob', + id: '1', + }, + }, + errors: null, + fetching: false, + partial: false, + source: 'network', + stale: false, + variables: null, }) }) @@ -173,9 +290,7 @@ test('CacheOnly', async function () { export function createStore(plugins: ClientPlugin[]): DocumentStore { const client = new HoudiniClient({ url: 'URL', - pipeline() { - return plugins - }, + pipeline: [...plugins], }) return new DocumentStore({ @@ -203,6 +318,10 @@ export function createStore(plugins: ClientPlugin[]): DocumentStore { type: 'String', keyRaw: 'firstName', }, + __typename: { + type: 'String', + keyRaw: '__typename', + }, }, }, }, @@ -218,6 +337,7 @@ function fakeFetch({ viewer: { id: '1', firstName: 'bob', + __typename: 'User', }, }, errors: null, @@ -225,6 +345,7 @@ function fakeFetch({ variables: null, source: DataSource.Network, partial: false, + stale: false, }, }) { return (() => ({ diff --git a/packages/houdini/src/runtime/client/plugins/cache.ts b/packages/houdini/src/runtime/client/plugins/cache.ts index f0a2373d18..b42c81033c 100644 --- a/packages/houdini/src/runtime/client/plugins/cache.ts +++ b/packages/houdini/src/runtime/client/plugins/cache.ts @@ -52,6 +52,7 @@ export const cachePolicyPlugin = errors: null, source: DataSource.Cache, partial: value.partial, + stale: value.stale, }) } @@ -65,11 +66,12 @@ export const cachePolicyPlugin = errors: null, source: DataSource.Cache, partial: value.partial, + stale: value.stale, }) } // if we used the cache data and there's no followup necessary, we're done - if (useCache && !value.partial) { + if (useCache && !value.partial && !value.stale) { return } } diff --git a/packages/houdini/src/runtime/client/plugins/fetch.ts b/packages/houdini/src/runtime/client/plugins/fetch.ts index 0c8c0d264d..5c8b02bd6d 100644 --- a/packages/houdini/src/runtime/client/plugins/fetch.ts +++ b/packages/houdini/src/runtime/client/plugins/fetch.ts @@ -49,6 +49,7 @@ export const fetchPlugin = (target?: RequestHandler | string): ClientPlugin => { data: result.data, errors: !result.errors || result.errors.length === 0 ? null : result.errors, partial: false, + stale: false, source: DataSource.Network, }) }, diff --git a/packages/houdini/src/runtime/client/plugins/query.ts b/packages/houdini/src/runtime/client/plugins/query.ts index 2474fff4e7..78f569f759 100644 --- a/packages/houdini/src/runtime/client/plugins/query.ts +++ b/packages/houdini/src/runtime/client/plugins/query.ts @@ -48,6 +48,7 @@ export const queryPlugin: ClientPlugin = documentPlugin(ArtifactKind.Query, func errors: null, fetching: false, partial: false, + stale: false, source: DataSource.Cache, variables: ctx.variables ?? null, }) diff --git a/packages/houdini/src/runtime/client/plugins/subscription.ts b/packages/houdini/src/runtime/client/plugins/subscription.ts index a2908ffc16..f08f5bbb43 100644 --- a/packages/houdini/src/runtime/client/plugins/subscription.ts +++ b/packages/houdini/src/runtime/client/plugins/subscription.ts @@ -71,6 +71,7 @@ export function subscriptionPlugin(factory: SubscriptionHandler) { errors: [...(errors ?? [])], fetching: false, partial: true, + stale: false, source: DataSource.Network, variables: ctx.variables ?? null, }) @@ -79,6 +80,7 @@ export function subscriptionPlugin(factory: SubscriptionHandler) { clearSubscription?.() resolve(ctx, { partial: true, + stale: false, source: DataSource.Network, data: null, errors: [data as Error], diff --git a/packages/houdini/src/runtime/lib/config.ts b/packages/houdini/src/runtime/lib/config.ts index b41b9dbb03..b56d41f49c 100644 --- a/packages/houdini/src/runtime/lib/config.ts +++ b/packages/houdini/src/runtime/lib/config.ts @@ -114,6 +114,11 @@ export type ConfigFile = { */ defaultPartial?: boolean + /** + * Specifies after how long a data goes stale in miliseconds. (default: `undefined`) + */ + defaultLifetime?: number + /** * Specifies whether mutations should append or prepend list. For more information: https://www.houdinigraphql.com/api/graphql (default: `append`) */ diff --git a/packages/houdini/src/runtime/lib/index.ts b/packages/houdini/src/runtime/lib/index.ts index 632922a986..e8bb4b50c8 100644 --- a/packages/houdini/src/runtime/lib/index.ts +++ b/packages/houdini/src/runtime/lib/index.ts @@ -5,3 +5,4 @@ export * from './log' export * from './scalars' export * from './types' export * from './store' +export * from './key' diff --git a/packages/houdini/src/runtime/lib/key.test.ts b/packages/houdini/src/runtime/lib/key.test.ts new file mode 100644 index 0000000000..bd118a93cf --- /dev/null +++ b/packages/houdini/src/runtime/lib/key.test.ts @@ -0,0 +1,80 @@ +import * as graphql from 'graphql' +import { test, expect, describe } from 'vitest' + +import fieldKey from '../../codegen/generators/artifacts/fieldKey' +import { testConfig } from '../../test' +import { computeKey } from './key' + +const config = testConfig() + +// we need to make sure that the imperative API behaves similarly to the +// artifact generator +describe('evaluateKey', function () { + const table = [ + { + title: 'int', + args: { intValue: 1 }, + field: 'field', + expected: `field(intValue: 1)`, + }, + { + title: 'boolean', + args: { boolValue: true }, + field: 'field', + expected: `field(boolValue: true)`, + }, + { + title: 'float', + args: { floatValue: 1.2 }, + field: 'field', + expected: `field(floatValue: 1.2)`, + }, + { + title: 'id', + args: { idValue: '123' }, + field: 'field', + expected: `field(idValue: "123")`, + }, + { + title: 'complex values', + args: { where: { name: [{ _eq: 'SidneyA' }, { _eq: 'SidneyB' }] } }, + field: 'field', + expected: `field(where: { + name: [{ _eq: "SidneyA" } , { _eq: "SidneyB" } ] + })`, + }, + { + title: 'multiple', + args: { intValue: 1, stringValue: 'a' }, + field: 'field', + expected: `field(intValue: 1, stringValue: "a")`, + }, + { + title: 'multiple - args out of order', + args: { stringValue: 'a', intValue: 1 }, + field: 'field', + expected: `field(intValue: 1, stringValue: "a")`, + }, + { + title: 'multiple - field args out of order', + args: { stringValue: 'a', intValue: 1 }, + field: 'field', + expected: `field(stringValue: "a", intValue: 1)`, + }, + ] + + for (const row of table) { + test(row.title, function () { + // figure out the key we would have printed during codegen + const field = graphql + .parse(`{ ${row.expected} }`) + .definitions.find( + (def): def is graphql.OperationDefinitionNode => + def.kind === 'OperationDefinition' && def.operation === 'query' + )!.selectionSet.selections[0] as graphql.FieldNode + + // make sure we matched expectations + expect(computeKey(row)).toEqual(fieldKey(config, field)) + }) + } +}) diff --git a/packages/houdini/src/runtime/lib/key.ts b/packages/houdini/src/runtime/lib/key.ts new file mode 100644 index 0000000000..8bae969337 --- /dev/null +++ b/packages/houdini/src/runtime/lib/key.ts @@ -0,0 +1,33 @@ +export const computeKey = ({ field, args }: { field: string; args?: { [key: string]: any } }) => { + const keys = Object.keys(args ?? {}) + keys.sort() + + return args && keys.length > 0 + ? `${field}(${keys + .map((key) => `${key}: ${stringifyObjectWithNoQuotesOnKeys(args[key])}`) + .join(', ')})` + : field +} + +const stringifyObjectWithNoQuotesOnKeys = (obj_from_json: {}): string => { + // In case of an array we'll stringify all objects. + if (Array.isArray(obj_from_json)) { + return `[${obj_from_json + .map((obj) => `${stringifyObjectWithNoQuotesOnKeys(obj)}`) + .join(', ')}]` + } + // not an object, stringify using native function + if ( + typeof obj_from_json !== 'object' || + obj_from_json instanceof Date || + obj_from_json === null + ) { + return JSON.stringify(obj_from_json).replace(/"([^"]+)":/g, '$1: ') + } + // Implements recursive object serialization according to JSON spec + // but without quotes around the keys. + return `{${Object.keys(obj_from_json) + // @ts-ignore + .map((key) => `${key}: ${stringifyObjectWithNoQuotesOnKeys(obj_from_json[key])}`) + .join(', ')}}` +} diff --git a/packages/houdini/src/runtime/lib/types.ts b/packages/houdini/src/runtime/lib/types.ts index 0856525510..ef4ea22845 100644 --- a/packages/houdini/src/runtime/lib/types.ts +++ b/packages/houdini/src/runtime/lib/types.ts @@ -196,7 +196,6 @@ export type SubscriptionSpec = { export type FetchQueryResult<_Data> = { result: RequestPayload<_Data | null> source: DataSource | null - partial: boolean } export type QueryResult<_Data = GraphQLObject, _Input = Record> = { @@ -204,6 +203,7 @@ export type QueryResult<_Data = GraphQLObject, _Input = Record> = { errors: { message: string }[] | null fetching: boolean partial: boolean + stale: boolean source: DataSource | null variables: _Input | null } diff --git a/packages/houdini/src/runtime/public/cache.ts b/packages/houdini/src/runtime/public/cache.ts index 51942dab32..300f902392 100644 --- a/packages/houdini/src/runtime/public/cache.ts +++ b/packages/houdini/src/runtime/public/cache.ts @@ -3,11 +3,13 @@ import { marshalInputs, type QueryArtifact } from '../lib' import { ListCollection } from './list' import { Record } from './record' import type { + ArgType, CacheTypeDef, IDFields, QueryInput, QueryList, QueryValue, + TypeFieldNames, TypeNames, ValidLists, } from './types' @@ -109,4 +111,14 @@ Please acknowledge this by setting acceptImperativeInstability to true in your c return } + + /** + * Mark some elements of the cache stale. + */ + markStale<_Type extends TypeNames, _Field extends TypeFieldNames>( + type?: _Type, + options: { field?: _Field; when?: ArgType } = {} + ): void { + return this._internal_unstable.markTypeStale(type, options) + } } diff --git a/packages/houdini/src/runtime/public/record.ts b/packages/houdini/src/runtime/public/record.ts index 0e7d21f083..2bdb87632c 100644 --- a/packages/houdini/src/runtime/public/record.ts +++ b/packages/houdini/src/runtime/public/record.ts @@ -4,10 +4,12 @@ import { keyFieldsForType } from '../lib/config' import type { FragmentArtifact, GraphQLObject } from '../lib/types' import type { Cache } from './cache' import type { + ArgType, CacheTypeDef, FragmentList, FragmentValue, FragmentVariables, + TypeFieldNames, ValidTypes, } from './types' @@ -70,6 +72,7 @@ export class Record> { // TODO: figure out a way to make this required when _Variables has a value // and optional when _Variables is never variables?: FragmentVariables, _Fragment> + forceStale?: boolean }) { // we have the data and the fragment, just pass them both to the cache this.#cache._internal_unstable.write({ @@ -82,10 +85,27 @@ export class Record> { artifact: args.fragment.artifact, input: args.variables, }) ?? undefined, + forceStale: args.forceStale, }) } delete() { this.#cache._internal_unstable.delete(this.#id) } + + /** + * Mark some elements of the record stale in the cache. + * @param field + * @param when + */ + markStale>({ + field, + when, + }: { + field?: Field + when?: ArgType + } = {}): void { + // mark the record + this.#cache._internal_unstable.markRecordStale(this.#id, { field, when }) + } } diff --git a/packages/houdini/src/runtime/public/tests/cache.test.ts b/packages/houdini/src/runtime/public/tests/cache.test.ts index 0e8ee8c870..5a3b670208 100644 --- a/packages/houdini/src/runtime/public/tests/cache.test.ts +++ b/packages/houdini/src/runtime/public/tests/cache.test.ts @@ -77,6 +77,7 @@ test('can read values', function () { ).toEqual({ data, partial: false, + stale: false, }) }) @@ -155,6 +156,7 @@ test('can write values', function () { ).toEqual({ data, partial: false, + stale: false, }) }) @@ -220,5 +222,6 @@ test('can read and write variables', function () { ).toEqual({ data, partial: false, + stale: false, }) }) diff --git a/packages/houdini/src/runtime/public/tests/record.test.ts b/packages/houdini/src/runtime/public/tests/record.test.ts index 97449b7afc..4f90f273b2 100644 --- a/packages/houdini/src/runtime/public/tests/record.test.ts +++ b/packages/houdini/src/runtime/public/tests/record.test.ts @@ -75,6 +75,7 @@ test('can read fragment', function () { .read({ fragment: testFragment(selection.fields.viewer.selection) }) ).toEqual({ partial: false, + stale: false, data: { id: '1', firstName: 'bob', @@ -184,6 +185,7 @@ test('can writeFragments', function () { cache.get('User', { id: '2' }).read({ fragment: testFragment(artifact.selection) }) ).toEqual({ partial: false, + stale: false, data: { firstName: 'michael', }, diff --git a/packages/houdini/src/runtime/public/tests/stale.test.ts b/packages/houdini/src/runtime/public/tests/stale.test.ts new file mode 100644 index 0000000000..91f2b66642 --- /dev/null +++ b/packages/houdini/src/runtime/public/tests/stale.test.ts @@ -0,0 +1,237 @@ +import { test, expect } from 'vitest' + +import { ArtifactKind, type FragmentArtifact } from '../../lib' +import type { Cache } from '../cache' +import { type CacheTypeDefTest, testCache } from './test' + +/** 1/ Helpers */ +const h_SetUserInCache = (cache: Cache, id: string) => { + const artifact: FragmentArtifact = { + kind: ArtifactKind.Fragment, + name: 'string', + raw: 'string', + hash: 'string', + rootType: 'string', + selection: { + fields: { + id: { + type: 'String', + keyRaw: 'id', + }, + firstName: { + type: 'String', + keyRaw: 'firstName', + }, + }, + }, + } + + const user = cache.get('User', { id }) + user.write({ + fragment: { + artifact, + }, + data: { + // @ts-expect-error: type definitions for the test api are busted + id, + firstName: 'newName', + }, + }) + + return user +} + +const h_GetUserRecord = (id: string, field: string = 'id') => { + return { + type: 'User', + id: `User:${id}`, + field, + } +} + +const h_SetCatInCache = (cache: Cache, id: string) => { + const artifact: FragmentArtifact = { + kind: ArtifactKind.Fragment, + name: 'string', + raw: 'string', + hash: 'string', + rootType: 'string', + selection: { + fields: { + id: { + type: 'String', + keyRaw: 'id', + }, + }, + }, + } + + const cat = cache.get('Cat', { id }) + cat.write({ + fragment: { + artifact, + }, + // @ts-expect-error: type definitions for the test api are busted + data: { + id, + }, + }) + + // cache.setFieldType({ parent: 'Cat', key: 'id', type: 'String', nullable: false }) + // cat.set({ field: 'id', value: id }) + return cat +} + +const h_GetCatRecord = (id: string) => { + return { + type: 'Cat', + id: `Cat:${id}`, + field: 'id', + } +} + +const h_GetFieldTime = ( + cache: Cache, + { id, field }: { id: string; field: string } +) => { + return cache._internal_unstable.getFieldTime(id, field) +} + +/** 2/ Tests */ +test('info doesn t exist in the stale manager, return undefined (not stale)', async function () { + const cache = testCache() + + // let's have a look at something that was never seen before, it should be undefined + expect(h_GetFieldTime(cache, h_GetCatRecord('1'))).toBe(undefined) +}) + +test('Mark all stale', async function () { + const cache = testCache() + + // create some users & Cats + h_SetUserInCache(cache, '1') + h_SetUserInCache(cache, '2') + h_SetCatInCache(cache, '8') + h_SetCatInCache(cache, '9') + + // Nothing should be null + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('8'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('9'))).not.toBe(null) + + // make all stale + cache.markStale() + + // every type `User` should be stale, but not the rest + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('8'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('9'))).toBe(null) +}) + +test('Mark a type stale', async function () { + const cache = testCache() + + // create some users & Cats + h_SetUserInCache(cache, '1') + h_SetUserInCache(cache, '2') + h_SetCatInCache(cache, '8') + h_SetCatInCache(cache, '9') + + // Nothing should be null + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('8'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('9'))).not.toBe(null) + + // make the type `User` stale + cache.markStale('User') + + // every type `User` should be stale, but not the rest + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('8'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetCatRecord('9'))).not.toBe(null) +}) + +test('Mark a type field stale', async function () { + const cache = testCache() + + // create some users + h_SetUserInCache(cache, '1') + h_SetUserInCache(cache, '2') + + // Nothing should be null + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'firstName'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2', 'firstName'))).not.toBe(null) + + // make the type `User` field `firstName` stale + cache.markStale('User', { field: 'firstName' }) + + // every type `User` should be stale, but not the rest + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'firstName'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('2', 'firstName'))).toBe(null) +}) + +test('Mark a record stale', async function () { + const cache = testCache() + + // create a user + const user1 = h_SetUserInCache(cache, '1') + + // check data state of stale + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'firstName'))).not.toBe(null) + + // mark a record stale + user1.markStale() + + // check data state of stale + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'firstName'))).toBe(null) +}) + +test('Mark a record field stale', async function () { + const cache = testCache() + + // create a user + const user1 = h_SetUserInCache(cache, '1') + + // check data state of stale + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'id'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'firstName'))).not.toBe(null) + + // mark a field stale + user1.markStale({ field: 'id' }) + + // check data state of stale + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'id'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'firstName'))).not.toBe(null) +}) + +test('Mark a record field stale when args', async function () { + const cache = testCache() + + // create a user + const user1 = h_SetUserInCache(cache, '1') + + // check data state of stale + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'id'))).not.toBe(null) + + // mark a field stale + // @ts-expect-error: generated type definitions are busted locally + user1.markStale({ field: 'id', when: { id: '1' } }) + + // check data state of stale + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'id(id: "1")'))).toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'id'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1', 'firstName'))).not.toBe(null) + expect(h_GetFieldTime(cache, h_GetUserRecord('1'))).not.toBe(null) +}) diff --git a/packages/houdini/src/runtime/public/tests/test.ts b/packages/houdini/src/runtime/public/tests/test.ts index 4d583c28e3..32e67c80c9 100644 --- a/packages/houdini/src/runtime/public/tests/test.ts +++ b/packages/houdini/src/runtime/public/tests/test.ts @@ -10,7 +10,7 @@ import { Cache } from '../cache' import type { Record } from '../record' // the type definition for our test cache -type CacheTypeDef = { +export type CacheTypeDefTest = { types: { __ROOT__: { idFields: {} @@ -25,33 +25,33 @@ type CacheTypeDef = { args: never } viewer: { - type: Record | null + type: Record | null args: never } pets: { - type: (Record | Record)[] + type: (Record | Record)[] args: never } listOfLists: { type: ( | ( - | Record - | Record + | Record + | Record | null - | (null | Record)[] + | (null | Record)[] )[] - | Record - | Record + | Record + | Record | null )[] args: never } users: { - type: Record[] | null + type: Record[] | null args: never } pet: { - type: Record | Record + type: Record | Record args: never } } @@ -69,7 +69,7 @@ type CacheTypeDef = { args: never } parent: { - type: Record + type: Record args: never } id: { @@ -93,7 +93,7 @@ type CacheTypeDef = { args: never } parent: { - type: Record | null + type: Record | null args: never } id: { @@ -155,7 +155,7 @@ type CacheTypeDef = { } } -export const testCache = () => new Cache(new _Cache(testConfigFile())) +export const testCache = () => new Cache(new _Cache(testConfigFile())) export const testFragment = (selection: SubscriptionSelection): { artifact: FragmentArtifact } => ({ artifact: { diff --git a/site/src/routes/api/cache/+page.svx b/site/src/routes/api/cache/+page.svx index ee833fe733..6677419fba 100644 --- a/site/src/routes/api/cache/+page.svx +++ b/site/src/routes/api/cache/+page.svx @@ -80,7 +80,6 @@ const allUsers = cache.read({ console.log('Users:', allUsers.data?.users) ``` - Your documents can use variables to read dynamic fields to match any situation. This includes both queries and fragments: @@ -295,3 +294,33 @@ If you only want to operate on the list depending on argument values, you can us ```typescript allFriends.when({ favorites: true }).append(user1) ``` + +## Stale Data + +If you want fine-grained logic for marking data as stale, you can use the programmatic api. For more +information on stale data in Houdini, check out the [Caching Data guide](/guides/caching-data#stale-data). + +```typescript +import { cache, graphql } from '$houdini' + +// Mark everything stale +cache.markStale() + +// Mark all type 'UserNodes' stale +cache.markStale('UserNodes') + +// Mark all type 'UserNodes' field 'totalCount' stale +cache.markStale('UserNodes', 'totalCount') + +// Mark the User 1 stale +const user = cache.get('User', { id: '1' }) +user.markStale() + +// Mark the User 1 field name stale +const user = cache.get('User', { id: '1' }) +user.markStale({ field: 'name' }) + +// Mark the name field when the pattern field argument is 'capitalize' +const user = cache.get('User', { id: '1' }) +user.markStale({ field: 'name', when: { pattern: 'capitalize' } }) +``` diff --git a/site/src/routes/api/client-plugins/+page.svx b/site/src/routes/api/client-plugins/+page.svx index 6520aabe88..e58dd5a4aa 100644 --- a/site/src/routes/api/client-plugins/+page.svx +++ b/site/src/routes/api/client-plugins/+page.svx @@ -399,6 +399,7 @@ type QueryResult = { errors: { message: string }[] | null fetching: boolean partial: boolean + stale: boolean source: DataSource | null variables: _Input | null } diff --git a/site/src/routes/api/config/+page.svx b/site/src/routes/api/config/+page.svx index 91932d49cd..205a88542b 100644 --- a/site/src/routes/api/config/+page.svx +++ b/site/src/routes/api/config/+page.svx @@ -41,6 +41,7 @@ By default, your config file can contain the following values: - `cacheBufferSize` (optional, default: `10`): The number of queries that must occur before a value is removed from the cache. For more information, see the [Caching Guide](/guides/caching-data). - `defaultCachePolicy` (optional, default: `"CacheOrNetwork"`): The default cache policy to use for queries. For a list of the policies or other information see the [Caching Guide](/guides/caching-data). - `defaultPartial` (optional, default: `false`): specifies whether or not the cache should always use partial data. For more information, check out the [Partial Data guide](/guides/caching-data#partial-data). +- `defaultLifetime` (optional, default: `undefined`): Specifies after how long a data goes stale in miliseconds. [Cache stale](/api/cache#stale). - `defaultKeys` (optional, default: `["id"]`): A list of fields to use when computing a record's id. The default value is `['id']`. For more information see the [Caching Guide](/guides/caching-data#custom-ids). - `types` (optional): an object that customizes the resolution behavior for a specific type. For more information see the [Caching Guide](/guides/caching-data#custom-ids). - `logLevel` (optional, default: `"summary"`): Specifies the style of logging houdini will use when generating your file. One of "quiet", "full", "summary", or "short-summary". diff --git a/site/src/routes/guides/caching-data/+page.svx b/site/src/routes/guides/caching-data/+page.svx index 64350e2a65..f160bc24ea 100644 --- a/site/src/routes/guides/caching-data/+page.svx +++ b/site/src/routes/guides/caching-data/+page.svx @@ -202,3 +202,45 @@ export default { } } ``` + +## Stale Data + +In some cases it can be useful to mark data as "stale" so that next time it is requested, +it will be refetched over the network. This can be done in two ways: + +### Globally after a timeout + +If you set `defaultLifetime` in your [config file](/api/config#fields) then data will +get automatically marked stale after a certain time (in milliseconds). For example, you can +configure so that any data older than 7 minutes is refreshed (example: `defaultLifetime: 7 * 60 * 1000`). +When this happens, the cached data will still be returned but a new query will be sent +(effectively making the cache policy `CacheAndNetwork`). + +### Programmatically + +If you want more fine-grained logic for marking data as stale, you can use the programmatic api: + +```typescript +import { cache, graphql } from '$houdini' + +// Mark everything stale +cache.markStale() + +// Mark all type 'UserNodes' stale +cache.markStale('UserNodes') + +// Mark all type 'UserNodes' field 'totalCount' stale +cache.markStale('UserNodes', 'totalCount') + +// Mark the User 1 stale +const user = cache.get('User', { id: '1' }) +user.markStale() + +// Mark the User 1 field name stale +const user = cache.get('User', { id: '1' }) +user.markStale({ field: 'name' }) + +// Mark the name field when the pattern field argument is 'capitalize' +const user = cache.get('User', { id: '1' }) +user.markStale({ field: 'name', when: { pattern: 'capitalize' } }) +```