feat: enhance entity management and reactivity in vue-sync-engine
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import code from './assets/snippet?raw';
|
// Подсветка выполняется на этапе сборки — в браузер уходит готовый HTML,
|
||||||
import ShikiCode from './ShikiCode/ShikiCode.vue';
|
// без Shiki/грамматик в бандле.
|
||||||
|
import codeHtml from './assets/snippet.ts?shiki';
|
||||||
|
import ShikiStatic from './ShikiCode/ShikiStatic.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="h-full w-full flex">
|
<main class="h-full w-full flex">
|
||||||
<div class="m-auto max-w-136 rounded-3xl overflow-clip">
|
<div class="m-auto max-w-136 rounded-3xl overflow-clip">
|
||||||
<ShikiCode :code line-numbers />
|
<ShikiStatic :html="codeHtml" line-numbers />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { ShikiTransformer } from 'shiki/core'
|
import type { ShikiTransformer } from 'shiki/core'
|
||||||
import { useShikiHighlight } from './useShikiHighlight'
|
import { useShikiHighlight } from './useShikiHighlight'
|
||||||
|
import './shiki-host.css'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -48,32 +49,3 @@ const gutterStyle = computed(() => {
|
|||||||
<pre class="shiki-fallback"><code>{{ code }}</code></pre>
|
<pre class="shiki-fallback"><code>{{ code }}</code></pre>
|
||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.shiki-host .shiki {
|
|
||||||
padding: 1rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--color-zinc-300) var(--color-zinc-950);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shiki-host[data-line-numbers] .shiki code {
|
|
||||||
counter-reset: shiki-line calc(var(--shiki-line-start, 1) - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shiki-host[data-line-numbers] .shiki code .line::before {
|
|
||||||
counter-increment: shiki-line;
|
|
||||||
content: counter(shiki-line);
|
|
||||||
display: inline-block;
|
|
||||||
width: var(--shiki-gutter-width, 2ch);
|
|
||||||
margin-right: 1.25rem;
|
|
||||||
text-align: right;
|
|
||||||
color: color-mix(in srgb, currentColor 40%, transparent);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shiki иногда оставляет пустую финальную строку — прячем её номер */
|
|
||||||
.shiki-host[data-line-numbers] .shiki code .line:last-child:empty::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import './shiki-host.css'
|
||||||
|
|
||||||
|
// HTML приходит из импорта `*?shiki` — подсветка уже сделана на этапе сборки.
|
||||||
|
// Этот компонент не тянет ни Shiki, ни грамматики в клиентский бандл.
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
html: string
|
||||||
|
lineNumbers?: boolean
|
||||||
|
startLine?: number
|
||||||
|
}>(),
|
||||||
|
{ lineNumbers: false, startLine: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const gutterStyle = computed(() => {
|
||||||
|
const lines = props.html.match(/class="line"/g)?.length ?? 1;
|
||||||
|
const total = props.startLine + lines - 1;
|
||||||
|
return {
|
||||||
|
'--shiki-line-start': String(props.startLine),
|
||||||
|
'--shiki-gutter-width': `${String(total).length}ch`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="shiki-host"
|
||||||
|
:data-line-numbers="lineNumbers ? '' : undefined"
|
||||||
|
:style="gutterStyle"
|
||||||
|
v-html="html"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.shiki-host .shiki {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-zinc-300) var(--color-zinc-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shiki-host[data-line-numbers] .shiki code {
|
||||||
|
counter-reset: shiki-line calc(var(--shiki-line-start, 1) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shiki-host[data-line-numbers] .shiki code .line::before {
|
||||||
|
counter-increment: shiki-line;
|
||||||
|
content: counter(shiki-line);
|
||||||
|
display: inline-block;
|
||||||
|
width: var(--shiki-gutter-width, 2ch);
|
||||||
|
margin-right: 1.25rem;
|
||||||
|
text-align: right;
|
||||||
|
color: color-mix(in srgb, currentColor 40%, transparent);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shiki иногда оставляет пустую финальную строку — прячем её номер */
|
||||||
|
.shiki-host[data-line-numbers] .shiki code .line:last-child:empty::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import type { Plugin } from 'vite';
|
||||||
|
import {
|
||||||
|
createHighlighterCore,
|
||||||
|
type HighlighterCore,
|
||||||
|
type ShikiTransformer,
|
||||||
|
} from 'shiki/core';
|
||||||
|
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
|
||||||
|
|
||||||
|
const SHIKI_QUERY = 'shiki';
|
||||||
|
|
||||||
|
/** Расширение файла -> id грамматики Shiki. */
|
||||||
|
const EXT_TO_LANG: Record<string, string> = {
|
||||||
|
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
|
||||||
|
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx',
|
||||||
|
vue: 'vue', json: 'json', jsonc: 'jsonc', css: 'css', scss: 'scss',
|
||||||
|
html: 'html', md: 'markdown', py: 'python', rs: 'rust', go: 'go',
|
||||||
|
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml', toml: 'toml', sql: 'sql',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ShikiPluginOptions {
|
||||||
|
/** Одиночная тема (по умолчанию aurora-x). Игнорируется, если задан `themes`. */
|
||||||
|
theme?: string;
|
||||||
|
/** Парные темы — Shiki отдаёт HTML с CSS-переменными для light/dark. */
|
||||||
|
themes?: { light: string; dark: string };
|
||||||
|
/** Доп. соответствия расширение -> язык поверх дефолтных. */
|
||||||
|
langAlias?: Record<string, string>;
|
||||||
|
/** Трансформеры Shiki (номера строк, диффы и т.п.). */
|
||||||
|
transformers?: ShikiTransformer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импорт `./snippet.ts?shiki` возвращает строку с уже подсвеченным HTML.
|
||||||
|
* Вся работа Shiki происходит в Node на этапе сборки/дева — в бандл клиента
|
||||||
|
* не попадает ни движок, ни грамматики. Zero runtime.
|
||||||
|
*
|
||||||
|
* Язык берётся из расширения файла, либо из `?shiki&lang=...`.
|
||||||
|
*/
|
||||||
|
export function shiki(options: ShikiPluginOptions = {}): Plugin {
|
||||||
|
const { theme = 'aurora-x', themes, transformers } = options;
|
||||||
|
const extToLang = { ...EXT_TO_LANG, ...options.langAlias };
|
||||||
|
|
||||||
|
let highlighter: Promise<HighlighterCore> | null = null;
|
||||||
|
const loadedLangs = new Set<string>();
|
||||||
|
const loadedThemes = new Set<string>();
|
||||||
|
|
||||||
|
const getHighlighter = () => {
|
||||||
|
highlighter ??= createHighlighterCore({
|
||||||
|
langs: [],
|
||||||
|
themes: [],
|
||||||
|
engine: createJavaScriptRegexEngine(),
|
||||||
|
});
|
||||||
|
return highlighter;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureLang = async (hl: HighlighterCore, lang: string) => {
|
||||||
|
if (loadedLangs.has(lang)) return;
|
||||||
|
const mod = await import(`shiki/langs/${lang}.mjs`);
|
||||||
|
await hl.loadLanguage(mod.default);
|
||||||
|
loadedLangs.add(lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureTheme = async (hl: HighlighterCore, name: string) => {
|
||||||
|
if (loadedThemes.has(name)) return;
|
||||||
|
const mod = await import(`shiki/themes/${name}.mjs`);
|
||||||
|
await hl.loadTheme(mod.default);
|
||||||
|
loadedThemes.add(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'vite-plugin-shiki',
|
||||||
|
enforce: 'pre',
|
||||||
|
|
||||||
|
async load(id) {
|
||||||
|
const [filepath, rawQuery] = id.split('?', 2);
|
||||||
|
if (!rawQuery) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(rawQuery);
|
||||||
|
if (!params.has(SHIKI_QUERY)) return;
|
||||||
|
|
||||||
|
const ext = filepath.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const lang = params.get('lang') ?? extToLang[ext] ?? ext ?? 'text';
|
||||||
|
|
||||||
|
// Перечитываем исходник сами + регистрируем как зависимость для HMR.
|
||||||
|
const source = await readFile(filepath, 'utf8');
|
||||||
|
this.addWatchFile(filepath);
|
||||||
|
|
||||||
|
const hl = await getHighlighter();
|
||||||
|
await ensureLang(hl, lang);
|
||||||
|
if (themes) {
|
||||||
|
await ensureTheme(hl, themes.light);
|
||||||
|
await ensureTheme(hl, themes.dark);
|
||||||
|
} else {
|
||||||
|
await ensureTheme(hl, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = hl.codeToHtml(source.replace(/\n$/, ''), {
|
||||||
|
lang,
|
||||||
|
...(themes ? { themes } : { theme }),
|
||||||
|
transformers,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { code: `export default ${JSON.stringify(html)}`, map: null };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/** Импорт `*?shiki` отдаёт уже подсвеченный HTML (см. vite-plugin-shiki). */
|
||||||
|
declare module '*?shiki' {
|
||||||
|
const html: string;
|
||||||
|
export default html;
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import tailwind from '@tailwindcss/vite';
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { shiki } from './src/ShikiCode/vite-plugin-shiki';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), tailwind()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwind(),
|
||||||
|
shiki({ theme: 'aurora-x' }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,6 +125,36 @@ describe('useQuery', () => {
|
|||||||
m.unmount()
|
m.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('data switches to the new subscription result after args change', async () => {
|
||||||
|
const list = vi.fn(async (a: { search?: string }): Promise<ListUsersResp> => ({
|
||||||
|
items: a.search ? [{ id: '2', name: 'Bob', age: 25 }] : [{ id: '1', name: 'Ada', age: 30 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
let api!: ReturnType<typeof useQuery<{ search?: string }, ListUsersResp, { ids: string[] }>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useQuery(defs.usersList, () => ({ search: search.value }))
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(api.data.value).toEqual({ ids: ['1'] })
|
||||||
|
|
||||||
|
search.value = 'b'
|
||||||
|
await nextTick()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
// Computeds must follow the swapped-in subscription ref, not stay bound to the old one.
|
||||||
|
expect(api.data.value).toEqual({ ids: ['2'] })
|
||||||
|
expect(api.isSuccess.value).toBe(true)
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('releases handle on unmount', async () => {
|
it('releases handle on unmount', async () => {
|
||||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
@@ -194,6 +224,33 @@ describe('useInfiniteQuery', () => {
|
|||||||
expect(list.mock.calls.length).toBeGreaterThanOrEqual(2)
|
expect(list.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||||
m.unmount()
|
m.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('pages switch to the new subscription result after args change', async () => {
|
||||||
|
const list = vi.fn(async (a: { search?: string }): Promise<ListUsersResp> => ({
|
||||||
|
items: a.search ? [{ id: '2', name: 'B', age: 2 }] : [{ id: '1', name: 'A', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
const search = ref('')
|
||||||
|
let api!: ReturnType<typeof useInfiniteQuery<{ search?: string }, ListUsersResp, string | null, { ids: string[]; nextCursor: string | null }>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useInfiniteQuery(defs.usersInfinite, () => ({ search: search.value }))
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(api.pages.value[0]?.ids).toEqual(['1'])
|
||||||
|
|
||||||
|
search.value = 'q'
|
||||||
|
await nextTick()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(api.pages.value[0]?.ids).toEqual(['2'])
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useEntity', () => {
|
describe('useEntity', () => {
|
||||||
|
|||||||
@@ -8,15 +8,21 @@ import { memoryAdapter } from '../adapters/storageAdapter'
|
|||||||
import { Status } from '../core/flags'
|
import { Status } from '../core/flags'
|
||||||
import { flush, makeUserDefs, type ListUsersResp, type User, UserEntity } from './fixtures'
|
import { flush, makeUserDefs, type ListUsersResp, type User, UserEntity } from './fixtures'
|
||||||
|
|
||||||
function setup(api: { list: any; update: any }) {
|
function setup(
|
||||||
|
api: { list: any; update: any },
|
||||||
|
options?: { defaultMaxPages?: number; entityCap?: number; entityGc?: boolean; defaultGcTime?: number },
|
||||||
|
) {
|
||||||
const defs = makeUserDefs(api)
|
const defs = makeUserDefs(api)
|
||||||
const storage = memoryAdapter()
|
const storage = memoryAdapter()
|
||||||
const { client, server } = createInlineTransport()
|
const { client, server } = createInlineTransport()
|
||||||
let onlineCb: (() => void) | null = null
|
let onlineCb: (() => void) | null = null
|
||||||
let online = true
|
let online = true
|
||||||
createQueryGraph({
|
const graph = createQueryGraph({
|
||||||
storage,
|
storage,
|
||||||
endpoint: server,
|
endpoint: server,
|
||||||
|
defaultMaxPages: options?.defaultMaxPages,
|
||||||
|
entityGc: options?.entityGc,
|
||||||
|
defaultGcTime: options?.defaultGcTime,
|
||||||
registry: {
|
registry: {
|
||||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||||
queries: new Map<string, AnyQueryDef>([
|
queries: new Map<string, AnyQueryDef>([
|
||||||
@@ -31,12 +37,13 @@ function setup(api: { list: any; update: any }) {
|
|||||||
return () => {}
|
return () => {}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const mirror = createMirror()
|
const mirror = createMirror({ entityCap: options?.entityCap })
|
||||||
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 10 })
|
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 10 })
|
||||||
return {
|
return {
|
||||||
runtime,
|
runtime,
|
||||||
defs,
|
defs,
|
||||||
storage,
|
storage,
|
||||||
|
graph,
|
||||||
setOnline(v: boolean) {
|
setOnline(v: boolean) {
|
||||||
online = v
|
online = v
|
||||||
if (v && onlineCb) onlineCb()
|
if (v && onlineCb) onlineCb()
|
||||||
@@ -200,6 +207,48 @@ describe('useInfiniteQuery', () => {
|
|||||||
expect(state.value.data?.pages[1].ids).toEqual(['2'])
|
expect(state.value.data?.pages[1].ids).toEqual(['2'])
|
||||||
scope.stop()
|
scope.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('windows pages to maxPages, dropping the oldest page and its entity refs', async () => {
|
||||||
|
let call = 0
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => {
|
||||||
|
call++
|
||||||
|
if (call === 1) return { items: [{ id: '1', name: 'A', age: 1 }], nextCursor: 'c1' }
|
||||||
|
if (call === 2) return { items: [{ id: '2', name: 'B', age: 2 }], nextCursor: 'c2' }
|
||||||
|
return { items: [{ id: '3', name: 'C', age: 3 }], nextCursor: null }
|
||||||
|
})
|
||||||
|
const { runtime, defs, graph } = setup({ list, update: vi.fn() }, { defaultMaxPages: 2 })
|
||||||
|
|
||||||
|
const scope = effectScope()
|
||||||
|
let handle!: ReturnType<typeof runtime.subscribeQuery>
|
||||||
|
scope.run(() => {
|
||||||
|
handle = runtime.subscribeQuery(defs.usersInfinite.name, defs.usersInfinite.key({}), {})
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
type R = { ids: string[]; nextCursor: string | null }
|
||||||
|
const state = runtime.mirror.ensureQuery<{ pages: R[]; pageParams: unknown[] }>(handle.subId)
|
||||||
|
|
||||||
|
handle.fetchNextPage()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(state.value.data?.pages.length).toBe(2)
|
||||||
|
|
||||||
|
handle.fetchNextPage()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
// Only the last two pages are retained; the first (id '1') is dropped.
|
||||||
|
expect(state.value.data?.pages.length).toBe(2)
|
||||||
|
expect(state.value.data?.pages.map((p) => p.ids)).toEqual([['2'], ['3']])
|
||||||
|
expect(state.value.data?.pageParams.length).toBe(2)
|
||||||
|
|
||||||
|
// The worker node's entity refs are windowed in lockstep with the pages.
|
||||||
|
const node = [...graph.nodes.values()].find((n) => n.def.name === defs.usersInfinite.name)!
|
||||||
|
expect(node.entityRefs.map((r) => r.id)).toEqual(['2', '3'])
|
||||||
|
expect(node.pageRefCounts).toEqual([1, 1])
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('GC', () => {
|
describe('GC', () => {
|
||||||
@@ -217,3 +266,78 @@ describe('GC', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('worker entity GC', () => {
|
||||||
|
const users = () => ({ items: [{ id: '1', name: 'A', age: 1 }, { id: '2', name: 'B', age: 2 }], nextCursor: null })
|
||||||
|
|
||||||
|
it('keeps worker entities forever when entityGc is off (default)', async () => {
|
||||||
|
const { runtime, defs, graph } = setup({ list: vi.fn(users), update: vi.fn() }, { defaultGcTime: 15 })
|
||||||
|
const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size).toBe(2)
|
||||||
|
|
||||||
|
handle.release()
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size).toBe(2) // not reclaimed
|
||||||
|
})
|
||||||
|
|
||||||
|
it('frees entities once their only query node is garbage-collected', async () => {
|
||||||
|
const { runtime, defs, graph } = setup({ list: vi.fn(users), update: vi.fn() }, { entityGc: true, defaultGcTime: 15 })
|
||||||
|
const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size).toBe(2)
|
||||||
|
|
||||||
|
handle.release()
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size ?? 0).toBe(0) // reclaimed
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an entity alive while another query still references it', async () => {
|
||||||
|
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||||
|
const { runtime, defs, graph } = setup({ list, update: vi.fn() }, { entityGc: true, defaultGcTime: 15 })
|
||||||
|
const h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
const h2 = runtime.subscribeQuery(defs.usersInfinite.name, defs.usersInfinite.key({}), {})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size).toBe(1)
|
||||||
|
|
||||||
|
h1.release()
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size).toBe(1) // infinite query still holds it
|
||||||
|
|
||||||
|
h2.release()
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size ?? 0).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an in-flight mutation pins its entity against eviction until it settles', async () => {
|
||||||
|
let resolveMut!: (v: User) => void
|
||||||
|
const update = vi.fn(() => new Promise<User>((r) => { resolveMut = r }))
|
||||||
|
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||||
|
const { runtime, defs, graph } = setup({ list, update }, { entityGc: true, defaultGcTime: 15 })
|
||||||
|
|
||||||
|
const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size).toBe(1)
|
||||||
|
|
||||||
|
// Start an optimistic mutation (pins user '1'), then drop the only query referencing it.
|
||||||
|
void runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'X' } })
|
||||||
|
await flush()
|
||||||
|
handle.release()
|
||||||
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size).toBe(1) // pinned -> survived node GC
|
||||||
|
|
||||||
|
resolveMut({ id: '1', name: 'X', age: 1 })
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(graph.entitiesInMemory.get('user')?.size ?? 0).toBe(0) // unpinned + unreferenced -> freed
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { effectScope, nextTick, watchEffect } from 'vue'
|
import { effectScope, nextTick, watchEffect } from 'vue'
|
||||||
import { createMirror } from '../tab/mirror'
|
import { createMirror } from '../tab/mirror'
|
||||||
|
import { entityKey } from '../core/queryKey'
|
||||||
import { Op, Status } from '../core/flags'
|
import { Op, Status } from '../core/flags'
|
||||||
|
|
||||||
describe('mirror.applyEntityPatches', () => {
|
describe('mirror.applyEntityPatches', () => {
|
||||||
@@ -20,22 +21,22 @@ describe('mirror.applyEntityPatches', () => {
|
|||||||
expect(m.getEntity('user', '1')).toBeUndefined()
|
expect(m.getEntity('user', '1')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('triggers reactivity for all touched types', async () => {
|
it('triggers reactivity for every touched entity in a batch', async () => {
|
||||||
const m = createMirror()
|
const m = createMirror()
|
||||||
const seen = { user: 0, post: 0, tag: 0 }
|
const seen = { u1: 0, p1: 0, t1: 0 }
|
||||||
const scope = effectScope()
|
const scope = effectScope()
|
||||||
scope.run(() => {
|
scope.run(() => {
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
m.getEntity('user', 'noop')
|
m.getEntity('user', '1')
|
||||||
seen.user++
|
seen.u1++
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
m.getEntity('post', 'noop')
|
m.getEntity('post', 'p1')
|
||||||
seen.post++
|
seen.p1++
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
m.getEntity('tag', 'noop')
|
m.getEntity('tag', 't1')
|
||||||
seen.tag++
|
seen.t1++
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -49,9 +50,59 @@ describe('mirror.applyEntityPatches', () => {
|
|||||||
])
|
])
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(seen.user).toBeGreaterThan(before.user)
|
expect(seen.u1).toBeGreaterThan(before.u1)
|
||||||
expect(seen.post).toBeGreaterThan(before.post)
|
expect(seen.p1).toBeGreaterThan(before.p1)
|
||||||
expect(seen.tag).toBeGreaterThan(before.tag)
|
expect(seen.t1).toBeGreaterThan(before.t1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT re-run readers of unaffected sibling entities (fine-grained)', async () => {
|
||||||
|
const m = createMirror()
|
||||||
|
let reads1 = 0
|
||||||
|
let reads2 = 0
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => {
|
||||||
|
watchEffect(() => {
|
||||||
|
m.getEntity('user', '1')
|
||||||
|
reads1++
|
||||||
|
})
|
||||||
|
watchEffect(() => {
|
||||||
|
m.getEntity('user', '2')
|
||||||
|
reads2++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
const before2 = reads2
|
||||||
|
|
||||||
|
// Mutating user/1 must not invalidate the reader of user/2.
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { v: 1 } } }])
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(reads1).toBeGreaterThan(0)
|
||||||
|
expect(reads2).toBe(before2)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers a reader that initially saw undefined when its entity is created', async () => {
|
||||||
|
const m = createMirror()
|
||||||
|
let value: unknown
|
||||||
|
let runs = 0
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => {
|
||||||
|
watchEffect(() => {
|
||||||
|
value = m.getEntity('user', 'late')
|
||||||
|
runs++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
expect(value).toBeUndefined()
|
||||||
|
const before = runs
|
||||||
|
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: 'late', patch: { op: Op.Set, path: [], value: { id: 'late' } } }])
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(runs).toBeGreaterThan(before)
|
||||||
|
expect(value).toEqual({ id: 'late' })
|
||||||
scope.stop()
|
scope.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,6 +111,53 @@ describe('mirror.applyEntityPatches', () => {
|
|||||||
expect(() => m.applyEntityPatches([])).not.toThrow()
|
expect(() => m.applyEntityPatches([])).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('prunes the version ref when an entity is deleted', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
// A read creates the per-entity version ref.
|
||||||
|
m.getEntity('user', '1')
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { id: '1' } } }])
|
||||||
|
expect(m.versions.has(entityKey('user', '1'))).toBe(true)
|
||||||
|
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Delete, path: [] } }])
|
||||||
|
expect(m.versions.has(entityKey('user', '1'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create version refs for entities that are written but never read', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '99', patch: { op: Op.Set, path: [], value: { id: '99' } } }])
|
||||||
|
expect(m.versions.has(entityKey('user', '99'))).toBe(false)
|
||||||
|
expect(m.getEntity('user', '99')).toEqual({ id: '99' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays reactive after a delete prunes and the entity is re-created', async () => {
|
||||||
|
const m = createMirror()
|
||||||
|
let value: unknown
|
||||||
|
let runs = 0
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => {
|
||||||
|
watchEffect(() => {
|
||||||
|
value = m.getEntity('user', '1')
|
||||||
|
runs++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { v: 1 } } }])
|
||||||
|
await nextTick()
|
||||||
|
expect(value).toEqual({ v: 1 })
|
||||||
|
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Delete, path: [] } }])
|
||||||
|
await nextTick()
|
||||||
|
expect(value).toBeUndefined() // reader re-ran and re-created its ref
|
||||||
|
|
||||||
|
const after = runs
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { v: 2 } } }])
|
||||||
|
await nextTick()
|
||||||
|
expect(runs).toBeGreaterThan(after) // still reactive on the re-created entity
|
||||||
|
expect(value).toEqual({ v: 2 })
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
it('handles non-root delete by merging the path', () => {
|
it('handles non-root delete by merging the path', () => {
|
||||||
const m = createMirror()
|
const m = createMirror()
|
||||||
m.applyEntityPatches([
|
m.applyEntityPatches([
|
||||||
@@ -105,3 +203,97 @@ describe('mirror.query state', () => {
|
|||||||
expect(r2.value.status).toBe(Status.Idle)
|
expect(r2.value.status).toBe(Status.Idle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('mirror LRU cap', () => {
|
||||||
|
const set = (id: string, value: unknown = { id }) => ({
|
||||||
|
type: 'user',
|
||||||
|
id,
|
||||||
|
patch: { op: Op.Set, path: [] as never[], value },
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evicts the least-recently-used entity when over cap', () => {
|
||||||
|
const m = createMirror({ entityCap: 2 })
|
||||||
|
m.applyEntityPatches([set('1')])
|
||||||
|
m.applyEntityPatches([set('2')])
|
||||||
|
m.applyEntityPatches([set('3')]) // overflow -> evict oldest ('1')
|
||||||
|
|
||||||
|
expect(m.getEntity('user', '1')).toBeUndefined()
|
||||||
|
expect(m.getEntity('user', '2')).toEqual({ id: '2' })
|
||||||
|
expect(m.getEntity('user', '3')).toEqual({ id: '3' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a read marks an entity recently-used so it survives eviction', () => {
|
||||||
|
const m = createMirror({ entityCap: 2 })
|
||||||
|
m.applyEntityPatches([set('1')])
|
||||||
|
m.applyEntityPatches([set('2')])
|
||||||
|
m.getEntity('user', '1') // touch '1' -> now '2' is the LRU
|
||||||
|
m.applyEntityPatches([set('3')]) // evicts '2'
|
||||||
|
|
||||||
|
expect(m.getEntity('user', '1')).toEqual({ id: '1' })
|
||||||
|
expect(m.getEntity('user', '2')).toBeUndefined()
|
||||||
|
expect(m.getEntity('user', '3')).toEqual({ id: '3' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a write marks an entity recently-used so it survives eviction', () => {
|
||||||
|
const m = createMirror({ entityCap: 2 })
|
||||||
|
m.applyEntityPatches([set('1')])
|
||||||
|
m.applyEntityPatches([set('2')])
|
||||||
|
m.applyEntityPatches([set('1', { id: '1', v: 2 })]) // touch '1' -> '2' is LRU
|
||||||
|
m.applyEntityPatches([set('3')]) // evicts '2'
|
||||||
|
|
||||||
|
expect(m.getEntity('user', '1')).toEqual({ id: '1', v: 2 })
|
||||||
|
expect(m.getEntity('user', '2')).toBeUndefined()
|
||||||
|
expect(m.getEntity('user', '3')).toEqual({ id: '3' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps each type independently', () => {
|
||||||
|
const m = createMirror({ entityCap: 2 })
|
||||||
|
m.applyEntityPatches([
|
||||||
|
{ type: 'user', id: 'u1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
{ type: 'user', id: 'u2', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
{ type: 'user', id: 'u3', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
{ type: 'post', id: 'p1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
{ type: 'post', id: 'p2', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
])
|
||||||
|
expect(m.entities.get('user')!.size).toBe(2)
|
||||||
|
expect(m.entities.get('post')!.size).toBe(2)
|
||||||
|
expect(m.getEntity('user', 'u1')).toBeUndefined() // oldest user evicted
|
||||||
|
expect(m.getEntity('post', 'p1')).toBe(1) // posts within cap
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cap of 0 (default) never evicts', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
for (let i = 0; i < 100; i++) m.applyEntityPatches([set(String(i))])
|
||||||
|
expect(m.entities.get('user')!.size).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eviction prunes the version ref of an unobserved entity', () => {
|
||||||
|
const m = createMirror({ entityCap: 1 })
|
||||||
|
m.getEntity('user', '1') // create the version ref (no persistent reader)
|
||||||
|
m.applyEntityPatches([set('1')])
|
||||||
|
expect(m.versions.has(entityKey('user', '1'))).toBe(true)
|
||||||
|
|
||||||
|
m.applyEntityPatches([set('2')]) // evicts '1' and prunes its ref (nothing re-reads it)
|
||||||
|
expect(m.versions.has(entityKey('user', '1'))).toBe(false)
|
||||||
|
expect(m.entities.get('user')!.has('1')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eviction re-runs a stale reader (reactivity preserved)', async () => {
|
||||||
|
const m = createMirror({ entityCap: 1 })
|
||||||
|
let value: unknown
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => {
|
||||||
|
watchEffect(() => {
|
||||||
|
value = m.getEntity('user', '1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
m.applyEntityPatches([set('1')])
|
||||||
|
await nextTick()
|
||||||
|
expect(value).toEqual({ id: '1' })
|
||||||
|
|
||||||
|
m.applyEntityPatches([set('2')]) // evicts '1'
|
||||||
|
await nextTick()
|
||||||
|
expect(value).toBeUndefined() // reader re-ran on eviction
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, onScopeDispose, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
import { computed, onScopeDispose, shallowRef, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
||||||
import type { InfiniteQueryDef, QueryStatus } from '../core/types'
|
import type { InfiniteQueryDef, QueryStatus } from '../core/types'
|
||||||
import { Status } from '../core/flags'
|
import { Status } from '../core/flags'
|
||||||
import { hashKey } from '../core/queryKey'
|
import { hashKey } from '../core/queryKey'
|
||||||
@@ -26,7 +26,9 @@ export function useInfiniteQuery<TArgs, TResp, TPageParam, TResult>(
|
|||||||
|
|
||||||
const initial = toValue(args)
|
const initial = toValue(args)
|
||||||
let handle = engine.subscribeQuery(def.name, def.key(initial), initial)
|
let handle = engine.subscribeQuery(def.name, def.key(initial), initial)
|
||||||
let stateRef = engine.mirror.ensureQuery<InfinitePayload<TResult>>(handle.subId)
|
// Track the active subId reactively and resolve via ensureQuery (see useQuery for rationale).
|
||||||
|
const subId = shallowRef(handle.subId)
|
||||||
|
const state = () => engine.mirror.ensureQuery<InfinitePayload<TResult>>(subId.value).value
|
||||||
|
|
||||||
if (!def.staticHash) {
|
if (!def.staticHash) {
|
||||||
watch(
|
watch(
|
||||||
@@ -35,7 +37,7 @@ export function useInfiniteQuery<TArgs, TResp, TPageParam, TResult>(
|
|||||||
const next = toValue(args)
|
const next = toValue(args)
|
||||||
const prev = handle
|
const prev = handle
|
||||||
handle = engine.subscribeQuery(def.name, def.key(next), next)
|
handle = engine.subscribeQuery(def.name, def.key(next), next)
|
||||||
stateRef = engine.mirror.ensureQuery<InfinitePayload<TResult>>(handle.subId)
|
subId.value = handle.subId
|
||||||
prev.release()
|
prev.release()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -44,11 +46,11 @@ export function useInfiniteQuery<TArgs, TResp, TPageParam, TResult>(
|
|||||||
onScopeDispose(() => handle.release())
|
onScopeDispose(() => handle.release())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pages: computed(() => stateRef.value.data?.pages ?? []),
|
pages: computed(() => state().data?.pages ?? []),
|
||||||
pageParams: computed(() => stateRef.value.data?.pageParams ?? []),
|
pageParams: computed(() => state().data?.pageParams ?? []),
|
||||||
status: computed(() => stateRef.value.status),
|
status: computed(() => state().status),
|
||||||
error: computed(() => stateRef.value.error),
|
error: computed(() => state().error),
|
||||||
isLoading: computed(() => stateRef.value.status === Status.Pending),
|
isLoading: computed(() => state().status === Status.Pending),
|
||||||
fetchNextPage: () => handle.fetchNextPage(),
|
fetchNextPage: () => handle.fetchNextPage(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, onScopeDispose, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
import { computed, onScopeDispose, shallowRef, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
||||||
import type { InfiniteQueryDef, QueryDef, QueryStatus } from '../core/types'
|
import type { InfiniteQueryDef, QueryDef, QueryStatus } from '../core/types'
|
||||||
import { Status } from '../core/flags'
|
import { Status } from '../core/flags'
|
||||||
import { hashKey } from '../core/queryKey'
|
import { hashKey } from '../core/queryKey'
|
||||||
@@ -21,7 +21,11 @@ export function useQuery<TArgs, TResp, TResult>(
|
|||||||
|
|
||||||
const initial = toValue(args)
|
const initial = toValue(args)
|
||||||
let currentHandle = engine.subscribeQuery(def.name, def.key(initial), initial)
|
let currentHandle = engine.subscribeQuery(def.name, def.key(initial), initial)
|
||||||
let currentRef = engine.mirror.ensureQuery<TResult>(currentHandle.subId)
|
// Track the active subId reactively (not the state ref itself — passing a ref into shallowRef
|
||||||
|
// unwraps it). Resolving through ensureQuery() inside each computed means the computed tracks
|
||||||
|
// both `subId` and the resolved ref, so it re-runs on both an args switch and a data update.
|
||||||
|
const subId = shallowRef(currentHandle.subId)
|
||||||
|
const state = () => engine.mirror.ensureQuery<TResult>(subId.value).value
|
||||||
|
|
||||||
if (!def.staticHash) {
|
if (!def.staticHash) {
|
||||||
watch(
|
watch(
|
||||||
@@ -30,7 +34,7 @@ export function useQuery<TArgs, TResp, TResult>(
|
|||||||
const next = toValue(args)
|
const next = toValue(args)
|
||||||
const prev = currentHandle
|
const prev = currentHandle
|
||||||
currentHandle = engine.subscribeQuery(def.name, def.key(next), next)
|
currentHandle = engine.subscribeQuery(def.name, def.key(next), next)
|
||||||
currentRef = engine.mirror.ensureQuery<TResult>(currentHandle.subId)
|
subId.value = currentHandle.subId
|
||||||
prev.release()
|
prev.release()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -39,11 +43,11 @@ export function useQuery<TArgs, TResp, TResult>(
|
|||||||
onScopeDispose(() => currentHandle.release())
|
onScopeDispose(() => currentHandle.release())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: computed(() => currentRef.value.data),
|
data: computed(() => state().data),
|
||||||
status: computed(() => currentRef.value.status),
|
status: computed(() => state().status),
|
||||||
error: computed(() => currentRef.value.error),
|
error: computed(() => state().error),
|
||||||
isLoading: computed(() => currentRef.value.status === Status.Pending),
|
isLoading: computed(() => state().status === Status.Pending),
|
||||||
isSuccess: computed(() => currentRef.value.status === Status.Success),
|
isSuccess: computed(() => state().status === Status.Success),
|
||||||
isError: computed(() => currentRef.value.status === Status.Error),
|
isError: computed(() => state().status === Status.Error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface InfiniteQueryDef<TArgs = any, TResp = any, TPageParam = any, TR
|
|||||||
readonly kind: typeof Kind.Infinite
|
readonly kind: typeof Kind.Infinite
|
||||||
readonly initialPageParam: TPageParam
|
readonly initialPageParam: TPageParam
|
||||||
readonly getNextPageParam: (lastPage: TResult, allPages: TResult[]) => TPageParam | null | undefined
|
readonly getNextPageParam: (lastPage: TResult, allPages: TResult[]) => TPageParam | null | undefined
|
||||||
|
/** Keep at most this many pages in memory; older pages are dropped as new ones load. 0/undefined = unlimited. */
|
||||||
|
readonly maxPages?: number
|
||||||
readonly fetch: (args: TArgs, ctx: FetchCtx & { pageParam: TPageParam }) => Promise<TResp>
|
readonly fetch: (args: TArgs, ctx: FetchCtx & { pageParam: TPageParam }) => Promise<TResp>
|
||||||
readonly normalize?: (resp: TResp, args: TArgs, pageParam: TPageParam) => { entities?: Record<string, ReadonlyArray<unknown>>; result: TResult }
|
readonly normalize?: (resp: TResp, args: TArgs, pageParam: TPageParam) => { entities?: Record<string, ReadonlyArray<unknown>>; result: TResult }
|
||||||
readonly exec?: (args: TArgs, ctx: ExecCtx) => Promise<ExecResult>
|
readonly exec?: (args: TArgs, ctx: ExecCtx) => Promise<ExecResult>
|
||||||
@@ -85,6 +87,8 @@ export interface QuerySnapshot<TResult = unknown> {
|
|||||||
error?: { message: string }
|
error?: { message: string }
|
||||||
updatedAt?: number
|
updatedAt?: number
|
||||||
entityRefs?: ReadonlyArray<{ type: string; id: EntityId }>
|
entityRefs?: ReadonlyArray<{ type: string; id: EntityId }>
|
||||||
|
/** Per-page entityRef counts for infinite queries; lets page windowing survive hydration. */
|
||||||
|
pageRefCounts?: ReadonlyArray<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueuedMutation {
|
export interface QueuedMutation {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface WorkerBootstrapOptions {
|
|||||||
endpoint: ServerEndpoint
|
endpoint: ServerEndpoint
|
||||||
defaultStaleTime?: number
|
defaultStaleTime?: number
|
||||||
defaultGcTime?: number
|
defaultGcTime?: number
|
||||||
|
/** Default page cap for infinite queries without their own `maxPages`. 0 = unlimited. */
|
||||||
|
defaultMaxPages?: number
|
||||||
|
/** Reclaim worker-memory entities once no live query references them. Default false. */
|
||||||
|
entityGc?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bootstrapWorker(opts: WorkerBootstrapOptions): void {
|
export function bootstrapWorker(opts: WorkerBootstrapOptions): void {
|
||||||
@@ -33,16 +37,20 @@ export function bootstrapWorker(opts: WorkerBootstrapOptions): void {
|
|||||||
registry,
|
registry,
|
||||||
defaultStaleTime: opts.defaultStaleTime,
|
defaultStaleTime: opts.defaultStaleTime,
|
||||||
defaultGcTime: opts.defaultGcTime,
|
defaultGcTime: opts.defaultGcTime,
|
||||||
|
defaultMaxPages: opts.defaultMaxPages,
|
||||||
|
entityGc: opts.entityGc,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabEngineOptions {
|
export interface TabEngineOptions {
|
||||||
transport: Transport
|
transport: Transport
|
||||||
staleSubGcMs?: number
|
staleSubGcMs?: number
|
||||||
|
/** Max entities kept per type in the tab cache (LRU eviction). 0 = unlimited. */
|
||||||
|
entityCap?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTabEngine(opts: TabEngineOptions): TabRuntime {
|
export function createTabEngine(opts: TabEngineOptions): TabRuntime {
|
||||||
const mirror = createMirror()
|
const mirror = createMirror({ entityCap: opts.entityCap })
|
||||||
return createTabRuntime({ transport: opts.transport, mirror, staleSubGcMs: opts.staleSubGcMs })
|
return createTabRuntime({ transport: opts.transport, mirror, staleSubGcMs: opts.staleSubGcMs })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +61,12 @@ export interface EngineOptions {
|
|||||||
storage?: StorageAdapter
|
storage?: StorageAdapter
|
||||||
defaultStaleTime?: number
|
defaultStaleTime?: number
|
||||||
defaultGcTime?: number
|
defaultGcTime?: number
|
||||||
|
/** Default page cap for infinite queries without their own `maxPages`. 0 = unlimited. */
|
||||||
|
defaultMaxPages?: number
|
||||||
|
/** Max entities kept per type in the tab cache (LRU eviction). 0 = unlimited. */
|
||||||
|
entityCap?: number
|
||||||
|
/** Reclaim worker-memory entities once no live query references them. Default false. */
|
||||||
|
entityGc?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEngine(opts: EngineOptions): TabRuntime {
|
export function createEngine(opts: EngineOptions): TabRuntime {
|
||||||
@@ -66,8 +80,10 @@ export function createEngine(opts: EngineOptions): TabRuntime {
|
|||||||
endpoint: server,
|
endpoint: server,
|
||||||
defaultStaleTime: opts.defaultStaleTime,
|
defaultStaleTime: opts.defaultStaleTime,
|
||||||
defaultGcTime: opts.defaultGcTime,
|
defaultGcTime: opts.defaultGcTime,
|
||||||
|
defaultMaxPages: opts.defaultMaxPages,
|
||||||
|
entityGc: opts.entityGc,
|
||||||
})
|
})
|
||||||
return createTabEngine({ transport: client })
|
return createTabEngine({ transport: client, entityCap: opts.entityCap })
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallEngineOptions {
|
export interface InstallEngineOptions {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { shallowRef, triggerRef, type ShallowRef } from 'vue'
|
|||||||
import type { EntityId, EntityPatch, Patch, QueryStatus } from '../core/types'
|
import type { EntityId, EntityPatch, Patch, QueryStatus } from '../core/types'
|
||||||
import { Op, Status } from '../core/flags'
|
import { Op, Status } from '../core/flags'
|
||||||
import { applyPatch } from '../core/patches'
|
import { applyPatch } from '../core/patches'
|
||||||
|
import { entityKey } from '../core/queryKey'
|
||||||
|
|
||||||
export interface QueryState<T = unknown> {
|
export interface QueryState<T = unknown> {
|
||||||
status: QueryStatus
|
status: QueryStatus
|
||||||
@@ -9,16 +10,29 @@ export interface QueryState<T = unknown> {
|
|||||||
error: { message: string } | undefined
|
error: { message: string } | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMirror() {
|
export interface MirrorOptions {
|
||||||
|
/**
|
||||||
|
* Max entities kept per type. When exceeded, the least-recently-used entity is evicted.
|
||||||
|
* 0 (default) = unlimited. Reads and writes bump recency; set this above your largest
|
||||||
|
* live working set so eviction only ever reclaims off-screen (orphaned) entities.
|
||||||
|
*/
|
||||||
|
entityCap?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMirror(opts?: MirrorOptions) {
|
||||||
|
const cap = opts?.entityCap ?? 0
|
||||||
const entities = new Map<string, Map<EntityId, unknown>>()
|
const entities = new Map<string, Map<EntityId, unknown>>()
|
||||||
|
// Per-entity version refs (keyed by `type id`) give fine-grained reactivity:
|
||||||
|
// a reader of one entity only re-runs when *that* entity changes, not when any
|
||||||
|
// sibling of the same type does.
|
||||||
const versions = new Map<string, ShallowRef<number>>()
|
const versions = new Map<string, ShallowRef<number>>()
|
||||||
const queries = new Map<string, ShallowRef<QueryState>>()
|
const queries = new Map<string, ShallowRef<QueryState>>()
|
||||||
|
|
||||||
function typeVersion(type: string): ShallowRef<number> {
|
function entityVersion(key: string): ShallowRef<number> {
|
||||||
let v = versions.get(type)
|
let v = versions.get(key)
|
||||||
if (!v) {
|
if (!v) {
|
||||||
v = shallowRef(0)
|
v = shallowRef(0)
|
||||||
versions.set(type, v)
|
versions.set(key, v)
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -32,38 +46,97 @@ export function createMirror() {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write a value, moving the key to the most-recently-used position when a cap is active.
|
||||||
|
// (Map.set on an existing key keeps its original position, so we delete first.)
|
||||||
|
function setVal(bucket: Map<EntityId, unknown>, id: EntityId, val: unknown): void {
|
||||||
|
if (cap !== 0) bucket.delete(id)
|
||||||
|
bucket.set(id, val)
|
||||||
|
}
|
||||||
|
|
||||||
function getEntity<T>(type: string, id: EntityId): T | undefined {
|
function getEntity<T>(type: string, id: EntityId): T | undefined {
|
||||||
typeVersion(type).value
|
// Lazily create + track this entity's version so that a later create/update/delete
|
||||||
|
// of exactly this entity re-runs the calling effect — even if it currently reads undefined.
|
||||||
|
entityVersion(entityKey(type, id)).value
|
||||||
const b = entities.get(type)
|
const b = entities.get(type)
|
||||||
return b === undefined ? undefined : (b.get(id) as T | undefined)
|
if (b === undefined) return undefined
|
||||||
|
const v = b.get(id)
|
||||||
|
if (cap !== 0 && v !== undefined) {
|
||||||
|
// LRU touch: a read marks the entity as recently used so it survives eviction.
|
||||||
|
b.delete(id)
|
||||||
|
b.set(id, v)
|
||||||
|
}
|
||||||
|
return v as T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the entity's readers, then drop its version ref if the entity no longer exists.
|
||||||
|
// Readers re-run synchronously on the trigger, call getEntity, and lazily re-create a fresh
|
||||||
|
// ref (seeing undefined) — so pruning is safe and keeps `versions` from growing on churn.
|
||||||
|
// Refs for entities written-but-never-read are never created, so this is a no-op for them.
|
||||||
|
function bumpEntity(type: string, id: EntityId, key: string): void {
|
||||||
|
const v = versions.get(key)
|
||||||
|
if (v === undefined) return
|
||||||
|
triggerRef(v)
|
||||||
|
const b = entities.get(type)
|
||||||
|
if (b === undefined || !b.has(id)) versions.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict least-recently-used entities (the front of the Map) until the type is within cap.
|
||||||
|
function evictOverflow(type: string): void {
|
||||||
|
const b = entities.get(type)
|
||||||
|
if (b === undefined || b.size <= cap) return
|
||||||
|
while (b.size > cap) {
|
||||||
|
const oldest = b.keys().next().value as EntityId
|
||||||
|
b.delete(oldest)
|
||||||
|
const key = entityKey(type, oldest)
|
||||||
|
const vref = versions.get(key)
|
||||||
|
if (vref !== undefined) {
|
||||||
|
triggerRef(vref)
|
||||||
|
versions.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyEntityPatches(patches: EntityPatch[]): void {
|
function applyEntityPatches(patches: EntityPatch[]): void {
|
||||||
if (patches.length === 0) return
|
const n = patches.length
|
||||||
|
if (n === 0) return
|
||||||
|
|
||||||
|
// Fast path: a single patch (the common optimistic-update case) needs no Map.
|
||||||
|
if (n === 1) {
|
||||||
|
const p = patches[0]
|
||||||
|
const bucket = entityBucket(p.type)
|
||||||
|
const patch = p.patch
|
||||||
|
if (patch.op === Op.Delete && patch.path.length === 0) bucket.delete(p.id)
|
||||||
|
else setVal(bucket, p.id, applyPatch(bucket.get(p.id), patch))
|
||||||
|
bumpEntity(p.type, p.id, entityKey(p.type, p.id))
|
||||||
|
if (cap !== 0) evictOverflow(p.type)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let lastType = ''
|
let lastType = ''
|
||||||
let bucket: Map<EntityId, unknown> | undefined
|
let bucket: Map<EntityId, unknown> | undefined
|
||||||
let touchedFirst: string | undefined
|
// Dedupe touched entities so one patched twice in a batch fires once; retain type+id
|
||||||
let touchedRest: Set<string> | undefined
|
// so pruning can check current existence.
|
||||||
for (let i = 0; i < patches.length; i++) {
|
let touched: Map<string, { type: string; id: EntityId }> | undefined
|
||||||
|
let touchedTypes: Set<string> | undefined
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
const p = patches[i]
|
const p = patches[i]
|
||||||
if (p.type !== lastType) {
|
if (p.type !== lastType) {
|
||||||
lastType = p.type
|
lastType = p.type
|
||||||
bucket = entityBucket(lastType)
|
bucket = entityBucket(lastType)
|
||||||
if (touchedFirst === undefined) touchedFirst = lastType
|
|
||||||
else if (lastType !== touchedFirst) {
|
|
||||||
if (touchedRest === undefined) touchedRest = new Set()
|
|
||||||
touchedRest.add(lastType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const patch = p.patch
|
const patch = p.patch
|
||||||
if (patch.op === Op.Delete && patch.path.length === 0) {
|
if (patch.op === Op.Delete && patch.path.length === 0) {
|
||||||
bucket!.delete(p.id)
|
bucket!.delete(p.id)
|
||||||
} else {
|
} else {
|
||||||
bucket!.set(p.id, applyPatch(bucket!.get(p.id), patch))
|
setVal(bucket!, p.id, applyPatch(bucket!.get(p.id), patch))
|
||||||
}
|
}
|
||||||
|
const key = entityKey(p.type, p.id)
|
||||||
|
if (touched === undefined) touched = new Map()
|
||||||
|
if (!touched.has(key)) touched.set(key, { type: p.type, id: p.id })
|
||||||
|
if (cap !== 0) (touchedTypes ??= new Set()).add(p.type)
|
||||||
}
|
}
|
||||||
if (touchedFirst !== undefined) triggerRef(typeVersion(touchedFirst))
|
if (touched !== undefined) for (const [key, { type, id }] of touched) bumpEntity(type, id, key)
|
||||||
if (touchedRest !== undefined) for (const t of touchedRest) triggerRef(typeVersion(t))
|
if (touchedTypes !== undefined) for (const t of touchedTypes) evictOverflow(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureQuery<T>(subId: string): ShallowRef<QueryState<T>> {
|
function ensureQuery<T>(subId: string): ShallowRef<QueryState<T>> {
|
||||||
@@ -89,7 +162,7 @@ export function createMirror() {
|
|||||||
queries.delete(subId)
|
queries.delete(subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entities, getEntity, applyEntityPatches, ensureQuery, applyQueryPatch, dropQuery }
|
return { entities, versions, getEntity, applyEntityPatches, ensureQuery, applyQueryPatch, dropQuery }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Mirror = ReturnType<typeof createMirror>
|
export type Mirror = ReturnType<typeof createMirror>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface MutationQueueDeps {
|
|||||||
buildCtx: (forward: EntityPatch[], inverse: EntityPatch[]) => OptimisticCtx
|
buildCtx: (forward: EntityPatch[], inverse: EntityPatch[]) => OptimisticCtx
|
||||||
buildPostCtx: (post: EntityPatch[]) => OptimisticCtx
|
buildPostCtx: (post: EntityPatch[]) => OptimisticCtx
|
||||||
invalidate: (def: MutationDef, input: unknown, resp: unknown) => void
|
invalidate: (def: MutationDef, input: unknown, resp: unknown) => void
|
||||||
|
pinEntities: (patches: ReadonlyArray<EntityPatch>) => void
|
||||||
|
unpinEntities: (patches: ReadonlyArray<EntityPatch>) => void
|
||||||
isOnline: () => boolean
|
isOnline: () => boolean
|
||||||
onOnline: (cb: () => void) => () => void
|
onOnline: (cb: () => void) => () => void
|
||||||
onResult: (mutId: string, ok: boolean, data?: unknown, error?: { message: string }) => void
|
onResult: (mutId: string, ok: boolean, data?: unknown, error?: { message: string }) => void
|
||||||
@@ -32,7 +34,9 @@ export function createMutationQueue(deps: MutationQueueDeps) {
|
|||||||
const persisted = await deps.storage.mutations.readAll()
|
const persisted = await deps.storage.mutations.readAll()
|
||||||
for (const m of persisted) {
|
for (const m of persisted) {
|
||||||
if (m.seq > seq) seq = m.seq
|
if (m.seq > seq) seq = m.seq
|
||||||
inflight.set(m.id, { queued: m, inverse: m.inversePatches ?? [] })
|
const inverse = m.inversePatches ?? []
|
||||||
|
inflight.set(m.id, { queued: m, inverse })
|
||||||
|
deps.pinEntities(inverse) // protect restored optimistic entities until they settle
|
||||||
}
|
}
|
||||||
void drain()
|
void drain()
|
||||||
deps.onOnline(() => void drain())
|
deps.onOnline(() => void drain())
|
||||||
@@ -52,6 +56,7 @@ export function createMutationQueue(deps: MutationQueueDeps) {
|
|||||||
if (def.optimistic) {
|
if (def.optimistic) {
|
||||||
def.optimistic(input, deps.buildCtx(forward, inverse))
|
def.optimistic(input, deps.buildCtx(forward, inverse))
|
||||||
if (forward.length) deps.emitEntityPatches(forward)
|
if (forward.length) deps.emitEntityPatches(forward)
|
||||||
|
if (inverse.length) deps.pinEntities(inverse) // protect until the mutation settles
|
||||||
}
|
}
|
||||||
|
|
||||||
const queued: QueuedMutation = {
|
const queued: QueuedMutation = {
|
||||||
@@ -105,6 +110,7 @@ export function createMutationQueue(deps: MutationQueueDeps) {
|
|||||||
deps.invalidate(def, entry.queued.input, resp)
|
deps.invalidate(def, entry.queued.input, resp)
|
||||||
inflight.delete(entry.queued.id)
|
inflight.delete(entry.queued.id)
|
||||||
await deps.storage.mutations.delete(entry.queued.id)
|
await deps.storage.mutations.delete(entry.queued.id)
|
||||||
|
deps.unpinEntities(entry.inverse)
|
||||||
deps.onResult(entry.queued.id, true, resp)
|
deps.onResult(entry.queued.id, true, resp)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const networkLike = !deps.isOnline() || isNetworkError(err)
|
const networkLike = !deps.isOnline() || isNetworkError(err)
|
||||||
@@ -124,6 +130,7 @@ export function createMutationQueue(deps: MutationQueueDeps) {
|
|||||||
}
|
}
|
||||||
inflight.delete(entry.queued.id)
|
inflight.delete(entry.queued.id)
|
||||||
await deps.storage.mutations.delete(entry.queued.id)
|
await deps.storage.mutations.delete(entry.queued.id)
|
||||||
|
deps.unpinEntities(entry.inverse)
|
||||||
deps.onResult(entry.queued.id, false, undefined, { message: (err as Error)?.message ?? String(err) })
|
deps.onResult(entry.queued.id, false, undefined, { message: (err as Error)?.message ?? String(err) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { StorageAdapter } from '../adapters/storageAdapter'
|
import type { StorageAdapter } from '../adapters/storageAdapter'
|
||||||
import type { EntityDef, EntityId, EntityPatch, InfiniteQueryDef, MutationDef, OptimisticCtx, QueryDef, QuerySnapshot, QueryStatus } from '../core/types'
|
import type { EntityDef, EntityId, EntityPatch, InfiniteQueryDef, MutationDef, OptimisticCtx, QueryDef, QuerySnapshot, QueryStatus } from '../core/types'
|
||||||
import { Op, Status, Msg, Kind } from '../core/flags'
|
import { Op, Status, Msg, Kind } from '../core/flags'
|
||||||
import { hashKey } from '../core/queryKey'
|
import { hashKey, entityKey } from '../core/queryKey'
|
||||||
import type { ServerEndpoint, ClientMsg } from '../transport/protocol'
|
import type { ServerEndpoint, ClientMsg } from '../transport/protocol'
|
||||||
import { createMutationQueue } from './mutationQueue'
|
import { createMutationQueue } from './mutationQueue'
|
||||||
import { DEV } from '../__dev'
|
import { DEV } from '../__dev'
|
||||||
@@ -22,6 +22,9 @@ interface QueryNode {
|
|||||||
abort: AbortController | null
|
abort: AbortController | null
|
||||||
gcTimer: ReturnType<typeof setTimeout> | null
|
gcTimer: ReturnType<typeof setTimeout> | null
|
||||||
entityRefs: Array<{ type: string; id: EntityId }>
|
entityRefs: Array<{ type: string; id: EntityId }>
|
||||||
|
// Number of entityRefs contributed by each retained page (infinite queries only),
|
||||||
|
// so page windowing can drop the right slice of refs alongside the page.
|
||||||
|
pageRefCounts: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Registry {
|
interface Registry {
|
||||||
@@ -36,6 +39,14 @@ export interface QueryGraphOptions {
|
|||||||
registry: Registry
|
registry: Registry
|
||||||
defaultStaleTime?: number
|
defaultStaleTime?: number
|
||||||
defaultGcTime?: number
|
defaultGcTime?: number
|
||||||
|
/** Default page cap for infinite queries that don't set their own `maxPages`. 0 = unlimited. */
|
||||||
|
defaultMaxPages?: number
|
||||||
|
/**
|
||||||
|
* Reclaim worker-memory entities once no live query references them (and no in-flight
|
||||||
|
* mutation pins them). Uses exact reference counts from each node's entityRefs, so it
|
||||||
|
* only ever frees provably-orphaned entities. Default false.
|
||||||
|
*/
|
||||||
|
entityGc?: boolean
|
||||||
isOnline?: () => boolean
|
isOnline?: () => boolean
|
||||||
onOnline?: (cb: () => void) => () => void
|
onOnline?: (cb: () => void) => () => void
|
||||||
}
|
}
|
||||||
@@ -44,6 +55,11 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
const { storage, endpoint, registry } = opts
|
const { storage, endpoint, registry } = opts
|
||||||
const defaultStaleTime = opts.defaultStaleTime ?? 30_000
|
const defaultStaleTime = opts.defaultStaleTime ?? 30_000
|
||||||
const defaultGcTime = opts.defaultGcTime ?? 5 * 60_000
|
const defaultGcTime = opts.defaultGcTime ?? 5 * 60_000
|
||||||
|
const defaultMaxPages = opts.defaultMaxPages ?? 0
|
||||||
|
// Entity GC bookkeeping. When disabled, the maps are null and every retain/release/pin
|
||||||
|
// call short-circuits on the first line — zero overhead on the hot fetch path.
|
||||||
|
const entityRefCount = opts.entityGc ? new Map<string, number>() : null
|
||||||
|
const entityPins = opts.entityGc ? new Map<string, number>() : null
|
||||||
const isOnline = opts.isOnline ?? (() => (typeof navigator !== 'undefined' ? navigator.onLine : true))
|
const isOnline = opts.isOnline ?? (() => (typeof navigator !== 'undefined' ? navigator.onLine : true))
|
||||||
const onOnline =
|
const onOnline =
|
||||||
opts.onOnline ??
|
opts.onOnline ??
|
||||||
@@ -71,6 +87,71 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
return entityBucket(type).get(id)
|
return entityBucket(type).get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evictEntity(type: string, id: EntityId): void {
|
||||||
|
const b = entitiesInMemory.get(type)
|
||||||
|
if (b !== undefined) b.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ref = { type: string; id: EntityId }
|
||||||
|
|
||||||
|
// Increment the query-reference count for each ref. Always called before releaseRefs so an
|
||||||
|
// entity present in both the old and new ref sets never transiently drops to 0.
|
||||||
|
function retainRefs(refs: ReadonlyArray<Ref>): void {
|
||||||
|
if (entityRefCount === null) return
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const k = entityKey(refs[i].type, refs[i].id)
|
||||||
|
entityRefCount.set(k, (entityRefCount.get(k) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement counts; an entity that reaches 0 references and isn't pinned is freed immediately.
|
||||||
|
function releaseRefs(refs: ReadonlyArray<Ref>): void {
|
||||||
|
if (entityRefCount === null) return
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const r = refs[i]
|
||||||
|
const k = entityKey(r.type, r.id)
|
||||||
|
const c = (entityRefCount.get(k) ?? 0) - 1
|
||||||
|
if (c <= 0) {
|
||||||
|
entityRefCount.delete(k)
|
||||||
|
if (entityPins === null || !entityPins.has(k)) evictEntity(r.type, r.id)
|
||||||
|
} else {
|
||||||
|
entityRefCount.set(k, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically swap a node's referenced entities, retaining the new set before releasing the old.
|
||||||
|
function setNodeRefs(node: QueryNode, newRefs: Ref[]): void {
|
||||||
|
retainRefs(newRefs)
|
||||||
|
releaseRefs(node.entityRefs)
|
||||||
|
node.entityRefs = newRefs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pins protect entities touched by an in-flight mutation from eviction until it settles.
|
||||||
|
function pinEntities(patches: ReadonlyArray<{ type: string; id: EntityId }>): void {
|
||||||
|
if (entityPins === null) return
|
||||||
|
for (let i = 0; i < patches.length; i++) {
|
||||||
|
const p = patches[i]
|
||||||
|
const k = entityKey(p.type, p.id)
|
||||||
|
entityPins.set(k, (entityPins.get(k) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpinEntities(patches: ReadonlyArray<{ type: string; id: EntityId }>): void {
|
||||||
|
if (entityPins === null) return
|
||||||
|
for (let i = 0; i < patches.length; i++) {
|
||||||
|
const p = patches[i]
|
||||||
|
const k = entityKey(p.type, p.id)
|
||||||
|
const c = (entityPins.get(k) ?? 0) - 1
|
||||||
|
if (c <= 0) {
|
||||||
|
entityPins.delete(k)
|
||||||
|
if (entityRefCount === null || !entityRefCount.has(k)) evictEntity(p.type, p.id)
|
||||||
|
} else {
|
||||||
|
entityPins.set(k, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function emitEntityPatches(patches: EntityPatch[]): Promise<void> {
|
function emitEntityPatches(patches: EntityPatch[]): Promise<void> {
|
||||||
if (patches.length === 0) return Promise.resolve()
|
if (patches.length === 0) return Promise.resolve()
|
||||||
const writesByType = new Map<string, Array<{ key: EntityId; value: unknown }>>()
|
const writesByType = new Map<string, Array<{ key: EntityId; value: unknown }>>()
|
||||||
@@ -142,6 +223,7 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
abort: null,
|
abort: null,
|
||||||
gcTimer: null,
|
gcTimer: null,
|
||||||
entityRefs: [],
|
entityRefs: [],
|
||||||
|
pageRefCounts: [],
|
||||||
}
|
}
|
||||||
nodes.set(key, node)
|
nodes.set(key, node)
|
||||||
} else if (node.gcTimer !== null) {
|
} else if (node.gcTimer !== null) {
|
||||||
@@ -156,6 +238,8 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
const gc = node.def.gcTime ?? defaultGcTime
|
const gc = node.def.gcTime ?? defaultGcTime
|
||||||
node.gcTimer = setTimeout(() => {
|
node.gcTimer = setTimeout(() => {
|
||||||
if (node.subscribers.size === 0) {
|
if (node.subscribers.size === 0) {
|
||||||
|
releaseRefs(node.entityRefs) // free entities this node was the last to reference
|
||||||
|
node.entityRefs = []
|
||||||
nodes.delete(node.key)
|
nodes.delete(node.key)
|
||||||
void storage.queries.delete(node.key)
|
void storage.queries.delete(node.key)
|
||||||
}
|
}
|
||||||
@@ -187,7 +271,8 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (patches.length > 0) endpoint.broadcast({ type: Msg.EntityPatch, patches })
|
if (patches.length > 0) endpoint.broadcast({ type: Msg.EntityPatch, patches })
|
||||||
node.entityRefs = stored.entityRefs.slice()
|
setNodeRefs(node, stored.entityRefs.slice()) // node started empty; retains restored refs
|
||||||
|
if (stored.pageRefCounts) node.pageRefCounts = stored.pageRefCounts.slice()
|
||||||
}
|
}
|
||||||
node.result = stored.result
|
node.result = stored.result
|
||||||
node.status = Status.Success
|
node.status = Status.Success
|
||||||
@@ -289,14 +374,43 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
})
|
})
|
||||||
if (entities !== null) await emitEntityPatches(ingestEntities(entities, pageRefs))
|
if (entities !== null) await emitEntityPatches(ingestEntities(entities, pageRefs))
|
||||||
if (isInfinite) {
|
if (isInfinite) {
|
||||||
|
const maxPages = (node.def as InfiniteQueryDef).maxPages ?? defaultMaxPages
|
||||||
const prev = (node.result as { pages: unknown[]; pageParams: unknown[] } | undefined) ?? { pages: [], pageParams: [] }
|
const prev = (node.result as { pages: unknown[]; pageParams: unknown[] } | undefined) ?? { pages: [], pageParams: [] }
|
||||||
node.result = append
|
if (append) {
|
||||||
? { pages: [...prev.pages, pageResult], pageParams: [...prev.pageParams, effectivePageParam] }
|
// Incremental retain: only the new page's entities, never re-touching the window —
|
||||||
: { pages: [pageResult], pageParams: [effectivePageParam] }
|
// keeps append O(page) rather than O(total) for long infinite lists.
|
||||||
node.entityRefs = append ? node.entityRefs.concat(pageRefs) : pageRefs
|
retainRefs(pageRefs)
|
||||||
|
const pages = [...prev.pages, pageResult]
|
||||||
|
const pageParams = [...prev.pageParams, effectivePageParam]
|
||||||
|
let refs = node.entityRefs.concat(pageRefs)
|
||||||
|
// Counts stay aligned with pages unless they drifted (e.g. a hydrated snapshot
|
||||||
|
// without per-page counts). When aligned we can drop the exact ref slice.
|
||||||
|
const counts = node.pageRefCounts.length === prev.pages.length ? node.pageRefCounts.concat(pageRefs.length) : null
|
||||||
|
if (maxPages && pages.length > maxPages) {
|
||||||
|
const dropN = pages.length - maxPages
|
||||||
|
pages.splice(0, dropN)
|
||||||
|
pageParams.splice(0, dropN)
|
||||||
|
if (counts) {
|
||||||
|
let dropRefs = 0
|
||||||
|
for (let i = 0; i < dropN; i++) dropRefs += counts[i]
|
||||||
|
counts.splice(0, dropN)
|
||||||
|
if (dropRefs > 0) {
|
||||||
|
releaseRefs(refs.slice(0, dropRefs))
|
||||||
|
refs = refs.slice(dropRefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.result = { pages, pageParams }
|
||||||
|
node.entityRefs = refs
|
||||||
|
node.pageRefCounts = counts ?? []
|
||||||
|
} else {
|
||||||
|
node.result = { pages: [pageResult], pageParams: [effectivePageParam] }
|
||||||
|
setNodeRefs(node, pageRefs)
|
||||||
|
node.pageRefCounts = [pageRefs.length]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
node.result = pageResult
|
node.result = pageResult
|
||||||
node.entityRefs = pageRefs
|
setNodeRefs(node, pageRefs)
|
||||||
}
|
}
|
||||||
node.status = Status.Success
|
node.status = Status.Success
|
||||||
node.updatedAt = Date.now()
|
node.updatedAt = Date.now()
|
||||||
@@ -305,6 +419,7 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
result: node.result,
|
result: node.result,
|
||||||
updatedAt: node.updatedAt,
|
updatedAt: node.updatedAt,
|
||||||
entityRefs: node.entityRefs,
|
entityRefs: node.entityRefs,
|
||||||
|
pageRefCounts: isInfinite ? node.pageRefCounts : undefined,
|
||||||
}
|
}
|
||||||
await storage.queries.write([{ key: node.key, value: snap }])
|
await storage.queries.write([{ key: node.key, value: snap }])
|
||||||
pushSnapshotToSubscribers(node)
|
pushSnapshotToSubscribers(node)
|
||||||
@@ -463,6 +578,8 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
buildCtx,
|
buildCtx,
|
||||||
buildPostCtx,
|
buildPostCtx,
|
||||||
invalidate,
|
invalidate,
|
||||||
|
pinEntities,
|
||||||
|
unpinEntities,
|
||||||
isOnline,
|
isOnline,
|
||||||
onOnline,
|
onOnline,
|
||||||
onResult: (mutId, ok, data, error) =>
|
onResult: (mutId, ok, data, error) =>
|
||||||
@@ -478,7 +595,7 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
else if (msg.type === Msg.FetchNextPage) fetchNextPage(msg.subId)
|
else if (msg.type === Msg.FetchNextPage) fetchNextPage(msg.subId)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { nodes, subscribe, unsubscribe, fetchNextPage, queue }
|
return { nodes, entitiesInMemory, subscribe, unsubscribe, fetchNextPage, queue }
|
||||||
}
|
}
|
||||||
|
|
||||||
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user