From 2dcb15ce582ea219f5c76421598ddc9bdd3ca5b2 Mon Sep 17 00:00:00 2001 From: dangreen Date: Fri, 19 Jun 2026 16:03:48 +0400 Subject: [PATCH] feat(query): entities api rework --- .../common/src/stores-di/events.ts | 4 +- .../src/stores/events.ts | 4 +- .../src/stores/events.ts | 4 +- .../svelte-nano_kit-ssr/src/stores/events.ts | 4 +- packages/query/.size-limit.json | 2 +- packages/query/src/settings/entities.spec.ts | 8 +- packages/query/src/settings/entities.ts | 102 ++++++++++-------- website/src/content/docs/query/advanced.mdx | 6 +- .../docs/tutorial/optimistic-updates.mdx | 6 +- 9 files changed, 81 insertions(+), 59 deletions(-) diff --git a/examples/event-board/common/src/stores-di/events.ts b/examples/event-board/common/src/stores-di/events.ts index 7276cc2a..bb80a920 100644 --- a/examples/event-board/common/src/stores-di/events.ts +++ b/examples/event-board/common/src/stores-di/events.ts @@ -80,11 +80,11 @@ export function EventsList$() { }) }, [ - entities(data => ({ + entities((capture, data) => ({ ...data, pages: data.pages.map(page => ({ ...page, - events: page.events.map(EventEntity) + events: page.events.map(capture(EventEntity)) })) })) ] diff --git a/examples/event-board/preact-nano_kit-intl-ssr/src/stores/events.ts b/examples/event-board/preact-nano_kit-intl-ssr/src/stores/events.ts index 7276cc2a..bb80a920 100644 --- a/examples/event-board/preact-nano_kit-intl-ssr/src/stores/events.ts +++ b/examples/event-board/preact-nano_kit-intl-ssr/src/stores/events.ts @@ -80,11 +80,11 @@ export function EventsList$() { }) }, [ - entities(data => ({ + entities((capture, data) => ({ ...data, pages: data.pages.map(page => ({ ...page, - events: page.events.map(EventEntity) + events: page.events.map(capture(EventEntity)) })) })) ] diff --git a/examples/event-board/react-nano_kit-intl-ssr/src/stores/events.ts b/examples/event-board/react-nano_kit-intl-ssr/src/stores/events.ts index 7276cc2a..bb80a920 100644 --- a/examples/event-board/react-nano_kit-intl-ssr/src/stores/events.ts +++ b/examples/event-board/react-nano_kit-intl-ssr/src/stores/events.ts @@ -80,11 +80,11 @@ export function EventsList$() { }) }, [ - entities(data => ({ + entities((capture, data) => ({ ...data, pages: data.pages.map(page => ({ ...page, - events: page.events.map(EventEntity) + events: page.events.map(capture(EventEntity)) })) })) ] diff --git a/examples/event-board/svelte-nano_kit-ssr/src/stores/events.ts b/examples/event-board/svelte-nano_kit-ssr/src/stores/events.ts index 7276cc2a..bb80a920 100644 --- a/examples/event-board/svelte-nano_kit-ssr/src/stores/events.ts +++ b/examples/event-board/svelte-nano_kit-ssr/src/stores/events.ts @@ -80,11 +80,11 @@ export function EventsList$() { }) }, [ - entities(data => ({ + entities((capture, data) => ({ ...data, pages: data.pages.map(page => ({ ...page, - events: page.events.map(EventEntity) + events: page.events.map(capture(EventEntity)) })) })) ] diff --git a/packages/query/.size-limit.json b/packages/query/.size-limit.json index c5c665d6..8e273e31 100644 --- a/packages/query/.size-limit.json +++ b/packages/query/.size-limit.json @@ -3,7 +3,7 @@ "name": "All publics", "path": "dist/index.js", "import": "*", - "limit": "3.94 kB" + "limit": "3.96 kB" }, { "name": "Minimal set", diff --git a/packages/query/src/settings/entities.spec.ts b/packages/query/src/settings/entities.spec.ts index e52cc78b..eac386b8 100644 --- a/packages/query/src/settings/entities.spec.ts +++ b/packages/query/src/settings/entities.spec.ts @@ -58,9 +58,9 @@ describe('query', () => { entities(PostEntity) ]) const [$posts] = query(PostsKey, [], getPosts, [ - entities(postsPage => ({ + entities((capture, postsPage) => ({ ...postsPage, - posts: postsPage.posts.map(PostEntity) + posts: postsPage.posts.map(capture(PostEntity)) })) ]) const offPost = effect(() => { @@ -181,9 +181,9 @@ describe('query', () => { ) const firstPostKey = PostEntity(1) const [$posts] = query(PostsKey, [], getPosts, [ - entities(postsPage => ({ + entities((capture, postsPage) => ({ ...postsPage, - posts: postsPage.posts.map(PostEntity) + posts: postsPage.posts.map(capture(PostEntity)) })) ]) const [mutate] = mutation<[id: number, title: string, content: string], Post | null>( diff --git a/packages/query/src/settings/entities.ts b/packages/query/src/settings/entities.ts index 7ea64d1b..721eadf1 100644 --- a/packages/query/src/settings/entities.ts +++ b/packages/query/src/settings/entities.ts @@ -1,6 +1,9 @@ import type { PickNonEmptyValue } from '@nano_kit/store' import type { ClientSetting } from '../client.types.js' -import type { CacheKey } from '../CacheStorage.types.js' +import type { + CacheKey, + CacheShardKey +} from '../CacheStorage.types.js' import type { ClientContext, MutationClientContext, @@ -8,17 +11,27 @@ import type { } from '../ClientContext.js' import { queryKey } from '../cache.js' -export interface Entity { - /** - * Get the cache key for the entity by its identifier. - */ - (id: number | string): CacheKey<[id: number | string], T | null> - /** - * Get or upsert the entity in the cache. - */ - (entity: T): T +export type Entity = ( + (id: number | string | T) => CacheKey<[id: number | string], T | null> +) & CacheShardKey + +export interface EntityCapture { + ( + EntityFn: Entity + ): (entity: T) => T + ( + EntityFn: Entity, + entity: T + ): T } +export type EntityMapper = ( + ( + capture: EntityCapture, + data: T + ) => T +) | Entity + const ENTITY_KEY = '#entity' const EntityKey = queryKey(ENTITY_KEY) @@ -36,8 +49,6 @@ function isEntityRef(value: T): value is T & EntityRef { return ENTITY_KEY in value } -let currentCtx: ClientContext | null = null - /** * Create an entity manager for a specific entity type. * @param name - The name of the entity type. @@ -63,31 +74,19 @@ export function entity( name: string, id = (entity: T) => entity.id ) { - return (idOrRefOrEntity: number | string | null | undefined | T) => { + const entityKey = (idOrRefOrEntity: number | string | null | undefined | T) => { if (isIdentifier(idOrRefOrEntity)) { return EntityKey(name, idOrRefOrEntity) } - if (!idOrRefOrEntity || !currentCtx) { + if (!idOrRefOrEntity) { return idOrRefOrEntity } - if (isEntityRef(idOrRefOrEntity)) { - return currentCtx.$get(idOrRefOrEntity[ENTITY_KEY]).data - } - - const key = EntityKey(name, id(idOrRefOrEntity)) - - currentCtx.set(key, { - ...currentCtx.initial(), - data: idOrRefOrEntity - }) - - return { - ...idOrRefOrEntity, - [ENTITY_KEY]: key - } + return EntityKey(name, id(idOrRefOrEntity)) } + + return Object.assign(entityKey, EntityKey) } /** @@ -96,7 +95,7 @@ export function entity( * @returns The client setting function. */ export function entities( - mapper: (data: NoInfer>) => NoInfer> + mapper: NoInfer>> ): ClientSetting> /** @@ -105,25 +104,44 @@ export function entities( * @returns The client setting function. */ export function entities( - mapper: (data: NoInfer>) => NoInfer> + mapper: NoInfer>> ): ClientSetting> /* @__NO_SIDE_EFFECTS__ */ -export function entities(mapper: (data: unknown) => unknown) { +export function entities( + mapper: EntityMapper +) { return (ctx: ClientContext) => { - const safeMapper = (data: unknown) => { - if (data) { - try { - currentCtx = ctx - - return mapper(data) - } finally { - currentCtx = null - } + const capture = ( + EntityFn: Entity<{}>, + entity: unknown + ) => { + if (!entity) { + return (entity: unknown) => capture(EntityFn, entity) + } + + if (isEntityRef(entity)) { + return ctx.$get(entity[ENTITY_KEY]).data } - return data + const key = EntityFn(entity) + + ctx.set(key, { + ...ctx.initial(), + // params: key.params, + data: entity + }) + + return { + ...entity, + [ENTITY_KEY]: key + } } + const safeMapper = (data: {}) => data && ( + 'shard' in mapper + ? capture(mapper, data) + : mapper(capture as EntityCapture, data) + ) ctx.mapComputedData = ctx.mapData = safeMapper } diff --git a/website/src/content/docs/query/advanced.mdx b/website/src/content/docs/query/advanced.mdx index 8a85843b..7d57d49e 100644 --- a/website/src/content/docs/query/advanced.mdx +++ b/website/src/content/docs/query/advanced.mdx @@ -525,9 +525,9 @@ const [$posts] = query(PostsKey, [], () => ( ), [ /* Map entities in the page to entity references */ /* Also every refetch will update entities in the cache */ - entities(page => ({ + entities((capture, page) => ({ ...page, - posts: page.posts.map(PostEntity) + posts: page.posts.map(capture(PostEntity)) })) ]) @@ -564,6 +564,8 @@ With this setup: 2. Fetching specific post via `$post` updates the entity in `$posts` as well. 3. `updatePost` optimistically updates the entity, instantly reflecting changes in both `$post` and `$posts`. +The `capture` helper stores each entity in the shared entity cache and returns a reference for the query result. Passing `entities(PostEntity)` is the shorthand for a single entity result. + **When to use:** Complex applications where the same data (e.g., a "User" or "Product") appears in multiple places or lists and needs to stay synchronized. ### `tasks` diff --git a/website/src/content/docs/tutorial/optimistic-updates.mdx b/website/src/content/docs/tutorial/optimistic-updates.mdx index bc929aa5..d318db42 100644 --- a/website/src/content/docs/tutorial/optimistic-updates.mdx +++ b/website/src/content/docs/tutorial/optimistic-updates.mdx @@ -45,17 +45,19 @@ infinite( lastPage => lastPage.nextCursor, (q, category, cursor) => fetchEvents({ q, category, cursor }), [ - entities(data => ({ + entities((capture, data) => ({ ...data, pages: data.pages.map(page => ({ ...page, - events: page.events.map(EventEntity) + events: page.events.map(capture(EventEntity)) })) })) ] ) ``` +`capture(EventEntity)` stores every event in the shared entity cache and leaves references inside the list pages. + The mutation also needs `$data` from the query client: ```ts