From 660989085c95ad0a5ae06117af4af3338b22fc76 Mon Sep 17 00:00:00 2001 From: robonen Date: Thu, 13 Aug 2026 02:29:44 +0700 Subject: [PATCH] feat: add support for #super imports in vite-layers --- vite-layers/README.md | 29 +- vite-layers/example/apps/aurora/src/main.ts | 7 +- vite-layers/example/apps/brand/src/main.ts | 7 +- vite-layers/src/devtools.ts | 51 +-- vite-layers/src/features.ts | 66 ++-- vite-layers/src/index.ts | 1 + vite-layers/src/kit.ts | 9 +- vite-layers/src/public.ts | 23 +- vite-layers/src/resolve.ts | 137 ++++++-- vite-layers/src/tsconfig.ts | 30 +- vite-layers/src/util.ts | 31 ++ vite-layers/test/resolve.test.ts | 145 +++++++- vite-layers/test/tsconfig.test.ts | 26 ++ vue-sync-engine/HOW_IT_WORKS.md | 360 ++++++++++++++++++++ 14 files changed, 778 insertions(+), 144 deletions(-) create mode 100644 vue-sync-engine/HOW_IT_WORKS.md diff --git a/vite-layers/README.md b/vite-layers/README.md index 488b9f2..64576ba 100644 --- a/vite-layers/README.md +++ b/vite-layers/README.md @@ -77,10 +77,34 @@ c12, вне графа Vite — сам он не следит), подхваты | Префикс | Куда резолвится | Примечания | |---|---|---| | `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` | +| `#super`, `#super/…` | первый совпавший файл **строго ниже** слоя импортёра | явный `super()` — предпочтительная форма, см. ниже | | `~~/…`, `@@/…` | `rootDir` проекта | обычный alias | | `#layers//…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени | | `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы | +## `super()`: доступ к затенённому файлу + +Оверрайд часто хочет не заменить базовый файл целиком, а обернуть его. Для этого есть `#super`: + +```ts +// apps/brand/src/main.ts — бренд без собственного bootstrap: реиспользует entry базы +import '#super' // мой же путь (main.ts), слоем ниже → apps/main/src/main.ts + +// apps/brand/src/components/Header.vue — оборачиваем базовый компонент +import BaseHeader from '#super/components/Header.vue' +``` + +- `#super/` — резолвит `` начиная со слоя **строго ниже** слоя импортёра + (работает из любого файла слоя, не только из одноимённого оверрайда); +- голый `#super` — сахар для «мой собственный путь, слоем ниже». + +`#super/*` типизируется в сгенерированном tsconfig (`paths` → слои ниже проектного), так что +go-to-definition ведёт в правильный файл; голая форма покрыта ambient-декларацией +(`.vite-layers/super.d.ts`) — типов не даёт, но и ошибок не создаёт (подходит для side-effect +импортов). Legacy-форма — self-import собственного пути (`import '@/main.ts'` изнутри `main.ts`, +Nuxt-парити) — продолжает работать, но не грепается, меняет смысл при копировании в другой файл и +резолвится TypeScript'ом в самого себя; в новом коде используйте `#super`. + ## Модель приоритета (из Nuxt) `layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину; @@ -187,8 +211,9 @@ UI рисуется целиком на сервере (json-render спеки ` ## Улучшения над Nuxt/c12 -1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`), - чтобы дотянуться до базового файла. В Nuxt такого механизма нет. +1. **`super()`** — явный `#super`/`#super/` (типизированный, greppable) плюс self-skip + (оверрайд может импортировать собственный путь `@/components/X`, чтобы дотянуться до базового + файла). В Nuxt такого механизма нет. 2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в `resolve`-хуке c12 обрывает рекурсию. diff --git a/vite-layers/example/apps/aurora/src/main.ts b/vite-layers/example/apps/aurora/src/main.ts index ae2c11b..2fd4de2 100644 --- a/vite-layers/example/apps/aurora/src/main.ts +++ b/vite-layers/example/apps/aurora/src/main.ts @@ -1,4 +1,3 @@ -// Aurora has no bootstrap logic of its own — it reuses the base layer's entry. -// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip -// (super() semantics) falls through to the next layer, i.e. main/src/main.ts. -import '@/main.ts' +// Aurora has no bootstrap logic of its own — `#super` re-resolves this file's own path +// (main.ts) from the next-lower layer, i.e. main/src/main.ts. +import '#super' diff --git a/vite-layers/example/apps/brand/src/main.ts b/vite-layers/example/apps/brand/src/main.ts index bcfa670..136e752 100644 --- a/vite-layers/example/apps/brand/src/main.ts +++ b/vite-layers/example/apps/brand/src/main.ts @@ -1,4 +1,3 @@ -// Brand has no bootstrap logic of its own — it reuses the base layer's entry. -// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip -// (super() semantics) falls through to the next layer, i.e. main/src/main.ts. -import '@/main.ts' +// Brand has no bootstrap logic of its own — `#super` re-resolves this file's own path +// (main.ts) from the next-lower layer, i.e. main/src/main.ts. +import '#super' diff --git a/vite-layers/src/devtools.ts b/vite-layers/src/devtools.ts index 79ee9b2..f0a00f4 100644 --- a/vite-layers/src/devtools.ts +++ b/vite-layers/src/devtools.ts @@ -1,5 +1,5 @@ -import { readdirSync, statSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' +import { statSync } from 'node:fs' +import { relative, resolve } from 'node:path' import type { ConfigEnv } from 'vite' import type { DevToolsServerCommandInput, @@ -14,7 +14,7 @@ import { flattenFeatures } from './features' import type { LayeredResolution, ResolveRecord } from './resolve' import { generateTsConfig, type GenerateTsConfigOptions } from './tsconfig' import type { LayerStack } from './types' -import { toPosix } from './util' +import { toPosix, walkFiles } from './util' // --------------------------------------------------------------------------------------------- // This module imports **only types** from `@vitejs/devtools-kit` — they are erased at emit, so the @@ -100,11 +100,10 @@ interface HookRow { } type TsconfigInfo = | { enabled: false } - | { enabled: true; paths: Record; appJson: string; nodeJson: string; dts: string } + | { enabled: true; paths: Record; appJson: string; nodeJson: string; dts: string; superDts: string } interface Snapshot { projectName: string - appDir: string mode: string command: string layers: LayerRow[] @@ -120,30 +119,6 @@ interface Snapshot { inheritanceTree: string } -/** Recursively list files under a directory (absolute paths); `[]` if it isn't a directory. */ -function walk(dir: string, out: string[] = []): string[] { - let entries: string[] - try { - entries = readdirSync(dir) - } catch { - return out - } - for (const name of entries) { - const abs = join(dir, name) - // Guard each stat: a broken symlink or a file unlinked between readdir and stat (a real TOCTOU - // window under the dev watcher) throws ENOENT — skip it instead of failing the whole snapshot. - let isDir: boolean - try { - isDir = statSync(abs).isDirectory() - } catch { - continue - } - if (isDir) walk(abs, out) - else out.push(abs) - } - return out -} - const asArray = (v: unknown): string[] => v == null ? [] : (Array.isArray(v) ? v : [v]).map(String) @@ -247,7 +222,7 @@ async function collectSnapshot(data: LayersDevtoolsData): Promise { }) const byPath = new Map() for (const { name, dir } of publicLayers) { - for (const abs of walk(dir)) { + for (const abs of walkFiles(dir)) { const rel = toPosix(relative(dir, abs)) ;(byPath.get(rel) ?? byPath.set(rel, []).get(rel)!).push(name) } @@ -288,12 +263,12 @@ async function collectSnapshot(data: LayersDevtoolsData): Promise { appJson: JSON.stringify(gen.tsconfig, null, 2), nodeJson: JSON.stringify(gen.nodeTsconfig, null, 2), dts: gen.dts, + superDts: gen.superDts, } } return { projectName: layers[0]?.name ?? 'app', - appDir: data.appDir, mode: data.env.mode, command: data.env.command, layers: layerRows, @@ -538,8 +513,6 @@ function buildFeaturesSpec(snap: Snapshot): JsonRenderSpec { interface ResolveResult { id: string - sub: string - query: string candidates: string[] error?: string } @@ -588,9 +561,10 @@ function buildResolverSpec(data: LayersDevtoolsData, query: string, result: Reso return s.build(s.vstack(sections, 14, 12)) } -/** Describe how a record resolved: a normal import, a `super()` self-import, or unresolved. */ +/** Describe how a record resolved: a normal import, `#super`, a `super()` self-import, or unresolved. */ function recordVia(r: ResolveRecord): string { if (r.resolved === null) return 'unresolved' + if (r.id.startsWith('#super')) return '#super' if (r.selfIndex < 0) return 'top match' return `super() #${r.selfIndex + 1}` } @@ -619,7 +593,7 @@ function resolveResultEls(s: Spec, data: LayersDevtoolsData, result: ResolveResu })), '240px', ), - s.text('A self-import (an override importing its own path) would super()-skip to the next row down.', 'caption'), + s.text('#super (or a self-import of an override’s own path) skips to the next row down.', 'caption'), ] } @@ -662,6 +636,7 @@ function buildAssetsSpec(snap: Snapshot): JsonRenderSpec { sections.push(s.card('.vite-layers/tsconfig.json', [s.code(ts.appJson, 'tsconfig.json')], true)) sections.push(s.card('.vite-layers/tsconfig.node.json', [s.code(ts.nodeJson, 'tsconfig.node.json')], true)) sections.push(s.card('.vite-layers/features.d.ts', [s.code(ts.dts, 'features.d.ts')], true)) + sections.push(s.card('.vite-layers/super.d.ts', [s.code(ts.superDts, 'super.d.ts')], true)) } else { sections.push(s.card('TypeScript', [s.text('tsconfig autogeneration is disabled (tsconfig: false).', 'caption')])) } @@ -693,18 +668,16 @@ const action = (name: string, handler: (params?: Record) => voi */ function runResolve(data: LayersDevtoolsData, rawId: unknown): ResolveResult { const id = String(rawId ?? '').trim() + if (!id) return { id, candidates: [], error: 'Enter an import id.' } const parsed = data.resolution.parse(id) - if (!id) return { id, sub: '', query: '', candidates: [], error: 'Enter an import id.' } if (!parsed) { return { id, - sub: '', - query: '', candidates: [], error: `Not a layered id — must start with one of: ${data.resolution.prefixes.join(', ')}`, } } - return { id, sub: parsed.sub, query: parsed.query, candidates: data.resolution.candidates(parsed.sub) } + return { id, candidates: data.resolution.candidates(parsed.sub) } } /** diff --git a/vite-layers/src/features.ts b/vite-layers/src/features.ts index e6be50a..0cf1d14 100644 --- a/vite-layers/src/features.ts +++ b/vite-layers/src/features.ts @@ -353,29 +353,29 @@ export function featurePlugin(features: Record = {}): Plugin { if (id.includes('/node_modules/')) return null if (!code.includes(FEATURE_MODULE) && !code.includes('vite-layers/feature')) return null - // A real macro module that fails to parse must NEVER be skipped silently — its feature() calls - // would ship uncompiled. oxc reports syntax errors in `errors` (it does not throw) and yields an - // empty/partial body, which otherwise looks like "no macro here". So: when the module references - // `#feature` in a from-clause, any parse failure is a hard build error; if `#feature` only shows - // up in a string/comment, stay out of the way and let the rest of the pipeline proceed. - let result: ReturnType - try { + // A real macro module that fails to parse must NEVER be skipped silently — its feature() calls + // would ship uncompiled. oxc reports syntax errors in `errors` (it does not throw) and yields an + // empty/partial body, which otherwise looks like "no macro here". So: when the module references + // `#feature` in a from-clause, any parse failure is a hard build error; if `#feature` only shows + // up in a string/comment, stay out of the way and let the rest of the pipeline proceed. + let result: ReturnType + try { result = parseSync(id.split('?', 1)[0]!, code, { sourceType: 'module', lang: langFromId(id) }) - } catch (err) { + } catch (err) { if (FEATURE_FROM_RE.test(code)) { this.error(`vite-layers: could not parse ${id} to compile its feature() calls — ${(err as Error)?.message ?? err}`) } return null - } - if (result.errors?.length && FEATURE_FROM_RE.test(code)) { + } + if (result.errors?.length && FEATURE_FROM_RE.test(code)) { this.error(`vite-layers: ${id} has syntax errors; cannot safely compile its feature() calls — ${result.errors[0]?.message ?? ''}`) - } - const program = result.program as unknown as AnyNode + } + const program = result.program as unknown as AnyNode - // Pass 1: collect the local binding name(s) imported from our module, and the import nodes. - const importDecls: AnyNode[] = [] - const locals = new Set() - for (const node of program.body as AnyNode[]) { + // Pass 1: collect the local binding name(s) imported from our module, and the import nodes. + const importDecls: AnyNode[] = [] + const locals = new Set() + for (const node of program.body as AnyNode[]) { if (node.type === 'ImportDeclaration' && isImportSource(node, FEATURE_SPECIFIERS)) { if (node.importKind === 'type') continue // `import type { feature }` — fully erased, ignore importDecls.push(node) @@ -400,15 +400,15 @@ export function featurePlugin(features: Record = {}): Plugin { ) { this.error('vite-layers: re-exporting the `feature` macro is not supported — import and call it directly.', node.start) } - } - if (locals.size === 0) return null + } + if (locals.size === 0) return null - // Pass 2: every reference to the binding (that isn't shadowed by a local of the same name) - // must be a direct `feature('known-key')` call; anything else is a hard error. - const s = new MagicString(code) - const edits: Array<[number, number, string]> = [] + // Pass 2: every reference to the binding (that isn't shadowed by a local of the same name) + // must be a direct `feature('known-key')` call; anything else is a hard error. + const s = new MagicString(code) + const edits: Array<[number, number, string]> = [] - const handleRef = (node: AnyNode, parent: AnyNode | null) => { + const handleRef = (node: AnyNode, parent: AnyNode | null) => { if (parent) { // Binding/declaration positions and non-reference uses of the name — not macro calls. if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportNamespaceSpecifier') return @@ -439,11 +439,11 @@ export function featurePlugin(features: Record = {}): Plugin { node.start, ) } - } + } - // Scope-aware descent: a reference is the macro only if no enclosing scope re-binds its name - // (so an unrelated local `feature` param/const/catch/… is left untouched, not falsely rejected). - const descend = (node: AnyNode, parent: AnyNode | null, shadow: Set) => { + // Scope-aware descent: a reference is the macro only if no enclosing scope re-binds its name + // (so an unrelated local `feature` param/const/catch/… is left untouched, not falsely rejected). + const descend = (node: AnyNode, parent: AnyNode | null, shadow: Set) => { let childShadow = shadow if (SCOPE_NODES.has(node.type)) { const bound = scopeBindings(node, locals) @@ -466,13 +466,15 @@ export function featurePlugin(features: Record = {}): Plugin { descend(child as AnyNode, node, childShadow) } } - } - descend(program, null, new Set()) + } + descend(program, null, new Set()) - for (const [start, end, text] of edits) s.overwrite(start, end, text) - for (const decl of importDecls) s.remove(decl.start, decl.end) + for (const [start, end, text] of edits) s.overwrite(start, end, text) + for (const decl of importDecls) s.remove(decl.start, decl.end) - return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) } + // 'boundary' maps token boundaries instead of every character — far cheaper than `true`, + // accurate enough to step over a substituted feature() call. + return { code: s.toString(), map: s.generateMap({ source: id, hires: 'boundary' }) } }, }, } diff --git a/vite-layers/src/index.ts b/vite-layers/src/index.ts index c805c5c..02856cd 100644 --- a/vite-layers/src/index.ts +++ b/vite-layers/src/index.ts @@ -17,6 +17,7 @@ export { createLayeredResolution, DEFAULT_EXTENSIONS, layersResolver, + SUPER_MODULE, type LayeredResolution, type LayersResolverOptions, type ParsedLayeredId, diff --git a/vite-layers/src/kit.ts b/vite-layers/src/kit.ts index cc97042..d0c4522 100644 --- a/vite-layers/src/kit.ts +++ b/vite-layers/src/kit.ts @@ -104,16 +104,17 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions '@@': project.rootDir, [FEATURE_MODULE]: FEATURE_FILE, // `#feature` → the macro entry (compiled away by featurePlugin) } - // `#layers/` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins). - for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir + // Low→high iteration order: for aliases the highest-priority layer overwrites (first-wins by + // name), for config fragments it merges last (higher layers override). + const lowToHigh = [...layers].reverse() + for (const l of lowToHigh) alias[`#layers/${l.name}`] = l.rootDir let vite: UserConfig = { resolve: { alias }, build: { outDir: options.outDir ?? `dist/${basename(appDir)}` }, } - // Layer fragments: low → high so higher-priority layers override. - for (const l of [...layers].reverse()) { + for (const l of lowToHigh) { const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite if (frag) vite = mergeConfig(vite, frag) } diff --git a/vite-layers/src/public.ts b/vite-layers/src/public.ts index 7d77e86..5291d34 100644 --- a/vite-layers/src/public.ts +++ b/vite-layers/src/public.ts @@ -1,25 +1,8 @@ -import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import sirv from 'sirv' import type { Plugin } from 'vite' -import { toPosix } from './util' - -/** Recursively list files under a directory (absolute paths). */ -function walk(dir: string, out: string[] = []): string[] { - for (const name of readdirSync(dir)) { - const abs = join(dir, name) - // Skip a broken symlink / file removed mid-walk (ENOENT) rather than aborting the public copy. - let isDir: boolean - try { - isDir = statSync(abs).isDirectory() - } catch { - continue - } - if (isDir) walk(abs, out) - else out.push(abs) - } - return out -} +import { toPosix, walkFiles } from './util' /** * Merged `relativePath → sourceAbs` map across all layers. Walked low→high so the higher-priority @@ -28,7 +11,7 @@ function walk(dir: string, out: string[] = []): string[] { function mergePublic(dirs: string[]): Map { const assets = new Map() for (const dir of [...dirs].reverse()) { - for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs) + for (const abs of walkFiles(dir)) assets.set(toPosix(relative(dir, abs)), abs) } return assets } diff --git a/vite-layers/src/resolve.ts b/vite-layers/src/resolve.ts index 40c9ba6..1765eb1 100644 --- a/vite-layers/src/resolve.ts +++ b/vite-layers/src/resolve.ts @@ -6,6 +6,22 @@ import { toPosix } from './util' /** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */ export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue'] +/** + * The explicit `super()` import specifier: `#super/` resolves `` from the layer + * **strictly below** the importer's; bare `#super` is sugar for "my own layer-relative path, one + * layer down". Unlike the implicit self-import form it is greppable, survives copy-paste, and + * `#super/*` is typed in the generated tsconfig. + */ +export const SUPER_MODULE = '#super' +const SUPER_PREFIX = `${SUPER_MODULE}/` +const SUPER_QUERY = `${SUPER_MODULE}?` + +/** Strip a `?query` suffix (allocation-free `split('?')[0]` — resolveId is per-import hot). */ +const stripQuery = (s: string): string => { + const q = s.indexOf('?') + return q < 0 ? s : s.slice(0, q) +} + export interface LayersResolverOptions { /** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */ roots: string[] @@ -52,14 +68,18 @@ export interface LayeredResolution { readonly roots: string[] readonly prefixes: string[] readonly extensions: string[] - /** Split a layered id into prefix/sub/query, or `null` if no prefix matches. */ + /** Split a layered id into prefix/sub/query (`#super/` counts as a prefix), or `null`. */ parse: (id: string) => ParsedLayeredId | null /** Ordered candidate files for a prefix-stripped sub-path, high→low priority. Cached. */ candidates: (sub: string) => string[] - /** Resolve a layered id (super()/self-skip + query preservation). `null` if not layered / no match. */ + /** Resolve a layered id (`#super` + self-skip `super()` + query preservation). `null` if not layered / no match. */ resolveId: (id: string, importer?: string) => string | null - /** Drop the candidate cache (call when files are added/removed — which layer wins can change). */ + /** Drop the whole candidate cache. Prefer the targeted invalidate* methods in dev. */ clear: () => void + /** Drop only the cache entries one added/removed file can affect (its sub + ext/index probe subs). */ + invalidateFile: (file: string) => void + /** Drop the cache entries under (or probing into) a removed directory. */ + invalidateDir: (dir: string) => void /** Recorded resolutions, newest first (empty unless `record` was enabled). */ records: () => ResolveRecord[] /** Clear the resolution log (the candidate cache is untouched). */ @@ -86,6 +106,10 @@ const isFile = (p: string): boolean => { export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution { const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options + // Layer index of a (posix, query-stripped) file, or -1 if outside every root. + const posixRoots = roots.map(r => toPosix(r)) + const layerOf = (file: string): number => posixRoots.findIndex(r => file.startsWith(`${r}/`)) + const probe = (root: string, sub: string): string | null => { const direct = resolve(root, sub) if (isFile(direct)) return direct @@ -117,7 +141,13 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered } const parse = (id: string): ParsedLayeredId | null => { - const prefix = prefixes.find(p => id.startsWith(p)) + // `#super/…` and bare `#super` always parse (bare → prefix `#super`, sub ''), so the devtools + // playground can list their candidates like any layered id. + const prefix = id.startsWith(SUPER_PREFIX) + ? SUPER_PREFIX + : id === SUPER_MODULE || id.startsWith(SUPER_QUERY) + ? SUPER_MODULE + : prefixes.find(p => id.startsWith(p)) if (!prefix) return null const q = id.indexOf('?') const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes @@ -125,13 +155,11 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered return { prefix, sub, query } } - // Bounded, de-duplicated resolution log (devtools). Keyed by a JSON-encoded `[id, importer]` pair - // (collision-proof, unlike a delimiter string) so repeated resolves of the same import (HMR re-runs) - // update one entry instead of flooding the log; a Map preserves insertion order, and re-inserting - // moves the entry to the end (most-recent-last). + // Bounded, de-duplicated resolution log (devtools). NUL-joined key: collision-proof (paths can't + // contain NUL), cheaper than JSON.stringify. Re-inserting moves an entry to the end (most-recent). const log = new Map() const remember = (rec: ResolveRecord) => { - const key = JSON.stringify([rec.id, rec.importer ?? null]) // collision-proof composite key + const key = `${rec.id}\0${rec.importer ?? '\0'}` if (log.has(key)) log.delete(key) log.set(key, rec) while (log.size > record) log.delete(log.keys().next().value!) @@ -147,24 +175,66 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered const parsed = parse(id) if (!parsed) return null - const self = importer ? toPosix(importer.split('?')[0]!) : undefined + const self = importer ? toPosix(stripQuery(importer)) : undefined + let list: string[] + let next: string | undefined + let selfIndex: number + + if (parsed.prefix === SUPER_PREFIX || parsed.prefix === SUPER_MODULE) { + // #super — explicit super(): first candidate in a layer STRICTLY BELOW the importer's, so it + // works from any file, not just an override of the same path. + const myLayer = self ? layerOf(self) : -1 + if (self === undefined || myLayer < 0) return null // importer outside the stack + // bare `#super` (sub === '') → the importer's own sub-path + list = candidates(parsed.sub || self.slice(posixRoots[myLayer]!.length + 1)) + next = list.find(f => layerOf(f) > myLayer) + selfIndex = list.indexOf(self) + } else { + // super() via self-skip: if the importer is one of the candidates (an override importing its + // own layered path), resolve to the NEXT-LOWER layer; a normal importer isn't in the list, so + // it resolves to the highest-priority match (index 0). Note: "first candidate that isn't me" + // would be wrong — for a shadowed middle layer it jumps UP to a higher override, and a + // top↔mid self-import chain would cycle. Position-aware skip stays correct at any depth. + list = candidates(parsed.sub) + selfIndex = self ? list.indexOf(self) : -1 + next = list[selfIndex + 1] + } - // super(): if the importer is one of the candidates (an override importing its own layered - // path), resolve to the NEXT-LOWER layer; a normal importer isn't in the list, so it resolves to - // the highest-priority match (index 0). Note: "first candidate that isn't me" would be wrong — - // for a shadowed middle layer it jumps UP to a higher override, and a top↔mid self-import chain - // would cycle. Position-aware skip makes super() correct through a deep extends chain. - const list = candidates(parsed.sub) - const selfIndex = self ? list.indexOf(self) : -1 - const next = list[selfIndex + 1] const resolved = next ? next + parsed.query : null - if (record > 0) remember({ id, importer: self, resolved, candidates: list, selfIndex }) return resolved }, clear() { cache.clear() }, + invalidateFile(file) { + const f = toPosix(file) + for (const root of posixRoots) { + // every root, not just the first — with nested roots a file has a different sub per root + if (!f.startsWith(`${root}/`)) continue + const sub = f.slice(root.length + 1) + cache.delete(sub) + for (const ext of extensions) { + if (!sub.endsWith(ext)) continue + const bare = sub.slice(0, -ext.length) // extension probe: `@/foo` ← foo.ts + cache.delete(bare) + if (bare.endsWith('/index')) cache.delete(bare.slice(0, -'/index'.length)) // `@/dir` ← dir/index.ts + break + } + } + }, + invalidateDir(dir) { + const d = toPosix(dir) + for (const root of posixRoots) { + if (!d.startsWith(`${root}/`)) continue + const sub = d.slice(root.length + 1) + const prefix = `${sub}/` + for (const key of cache.keys()) { + // `key === sub`: `@/dir` may have resolved via dir/index.* + if (key === sub || key.startsWith(prefix)) cache.delete(key) + } + } + }, records() { return [...log.values()].reverse() }, @@ -186,11 +256,10 @@ const isResolution = (v: LayersResolverOptions | LayeredResolution): v is Layere * Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then ``, * then `/index`. * - * Improvement over Nuxt: **self-skip** gives `super()` semantics at any depth. When the importer is - * itself one of the matches (an override importing its own layered path), resolution continues to the - * **next-lower** layer — so an override at `@/components/Foo.vue` can import `@/components/Foo.vue` to - * reach the layer beneath it. This composes through a deep `extends` chain: top→mid→base each resolve - * one step down, so multi-level overrides can each call `super()`. + * Improvement over Nuxt: `super()` semantics at any depth, in two forms — the explicit + * `#super`/`#super/` (preferred: greppable and typed, see {@link SUPER_MODULE}) and the + * implicit self-skip (Nuxt-parity: an override importing its own layered path resolves to the + * next-lower layer). Both compose through a deep `extends` chain, one step down per layer. * * Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a * pre-built resolution — `buildViteConfig` passes a shared instance so the devtools panel introspects @@ -198,20 +267,22 @@ const isResolution = (v: LayersResolverOptions | LayeredResolution): v is Layere */ export function layersResolver(source: LayersResolverOptions | LayeredResolution): Plugin { const resolution = isResolution(source) ? source : createLayeredResolution(source) - // Hook filter (rolldown): a RegExp matching the layered prefixes, so the bundler only invokes - // resolveId for `@/`/`~/` ids — every other specifier skips the JS round-trip. (resolveId filters - // accept only RegExp ids, not string globs.) https://rolldown.rs/in-depth/why-plugin-hook-filter - const idFilter = new RegExp(`^(?:${resolution.prefixes.map(escapeRegExp).join('|')})`) + // Hook filter (rolldown): only layered prefixes + `#super` reach the JS handler; every other + // specifier skips the round-trip. https://rolldown.rs/in-depth/why-plugin-hook-filter + const idFilter = new RegExp( + `^(?:${resolution.prefixes.map(escapeRegExp).join('|')}|${SUPER_MODULE}(?:/|\\?|$))`, + ) return { name: 'vite-layers:resolve', enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases configureServer(server) { - // A new/removed file can change which layer wins → drop the cache in dev. - const clear = () => resolution.clear() - server.watcher.on('add', clear) - server.watcher.on('unlink', clear) - server.watcher.on('unlinkDir', clear) + // A new/removed file can change which layer wins. Targeted invalidation, not a full clear — + // dev codegen (typed-router, dts emitters) would otherwise wipe the cache on every emit. + const invalidateFile = (file: string) => resolution.invalidateFile(file) + server.watcher.on('add', invalidateFile) + server.watcher.on('unlink', invalidateFile) + server.watcher.on('unlinkDir', dir => resolution.invalidateDir(dir)) }, resolveId: { filter: { id: idFilter }, diff --git a/vite-layers/src/tsconfig.ts b/vite-layers/src/tsconfig.ts index f939e2f..9c1f84a 100644 --- a/vite-layers/src/tsconfig.ts +++ b/vite-layers/src/tsconfig.ts @@ -30,6 +30,20 @@ export interface GenerateTsConfigOptions { hooks?: LayerHookable } +/** + * Ambient declaration for bare `#super` — importer-relative, so `paths` cannot express it; an empty + * ambient module at least makes side-effect imports type-check. MUST stay a **script** d.ts (no + * top-level import/export): inside a module file `declare module` becomes an augmentation of an + * unresolvable specifier (TS2664). + */ +const SUPER_DTS = [ + '// AUTO-GENERATED by vite-layers — do not edit.', + "// Bare `#super` re-resolves the importer's own path one layer down; it is importer-relative, so", + "// it cannot be typed. Use `#super/` for typed super() imports (see generated `paths`).", + "declare module '#super' {}", + '', +].join('\n') + /** A path not already starting with `.` — {@link rel} prefixes it with `./`. */ const LEADING_NON_DOT_RE = /^([^.])/ /** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */ @@ -95,6 +109,10 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt // the generated `features.d.ts` augmentation. Matches the alias buildViteConfig registers. [FEATURE_MODULE]: [rel(genDir, FEATURE_FILE)], } + // `#super/*` → the layers BELOW the project layer: exact for project-layer overrides (the common + // case), one layer too high for a middle-layer file — still better than the self-import form, + // which TS resolves to the importer itself. Bare `#super` is covered by SUPER_DTS instead. + if (layers.length > 1) paths['#super/*'] = layers.slice(1).map(l => `${rel(genDir, l.srcDir)}/*`) for (const l of layers) { // first-wins on duplicate names, mirroring the `#layers/` alias in buildViteConfig. const star = `#layers/${l.name}/*` @@ -109,7 +127,7 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt // are NOT here — they belong to the node config below. const base: TSConfig = { compilerOptions: { ...DEFAULT_COMPILER_OPTIONS }, - include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)], + include: ['./features.d.ts', './super.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)], exclude, } @@ -141,20 +159,24 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt genDir, dts: featuresDts(merged.features), dtsFile: resolve(genDir, 'features.d.ts'), + superDts: SUPER_DTS, + superDtsFile: resolve(genDir, 'super.d.ts'), } } /** - * Generate and write `/.vite-layers/{tsconfig.json,features.d.ts}` (tsconfig via pkg-types - * `writeTSConfig`). Returns the tsconfig path. + * Generate and write `/.vite-layers/{tsconfig.json,features.d.ts,super.d.ts}` (tsconfig via + * pkg-types `writeTSConfig`). Returns the tsconfig path. */ export async function writeTsConfig(appDir: string, opts?: GenerateTsConfigOptions): Promise { - const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile } = await generateTsConfig(appDir, opts) + const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile, superDts, superDtsFile } = + await generateTsConfig(appDir, opts) await mkdir(genDir, { recursive: true }) await Promise.all([ writeTSConfig(file, tsconfig), writeTSConfig(nodeFile, nodeTsconfig), writeFile(dtsFile, dts), + writeFile(superDtsFile, superDts), ]) return file } diff --git a/vite-layers/src/util.ts b/vite-layers/src/util.ts index f25c1cc..f44aa22 100644 --- a/vite-layers/src/util.ts +++ b/vite-layers/src/util.ts @@ -1,3 +1,6 @@ +import { readdirSync, statSync, type Dirent } from 'node:fs' +import { join } from 'node:path' + /** * Normalize a path to forward slashes (POSIX-style). c12 returns posix-style `cwd`s while Node's * `path` helpers are OS-native (backslashes on Windows); paths must be canonicalized to forward @@ -6,3 +9,31 @@ */ const SEPARATOR_RE = /\\/g export const toPosix = (p: string): string => p.replace(SEPARATOR_RE, '/') + +/** + * Recursively list files under a directory (absolute paths); `[]` if it isn't a directory. + * withFileTypes: half the syscalls of a per-entry statSync walk; only symlinks still need a stat + * (to follow them), and a broken one / a file unlinked mid-walk (ENOENT) is skipped, not fatal. + */ +export function walkFiles(dir: string, out: string[] = []): string[] { + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return out + } + for (const entry of entries) { + const abs = join(dir, entry.name) + let isDir = entry.isDirectory() + if (entry.isSymbolicLink()) { + try { + isDir = statSync(abs).isDirectory() + } catch { + continue + } + } + if (isDir) walkFiles(abs, out) + else out.push(abs) + } + return out +} diff --git a/vite-layers/test/resolve.test.ts b/vite-layers/test/resolve.test.ts index c22fa14..334a810 100644 --- a/vite-layers/test/resolve.test.ts +++ b/vite-layers/test/resolve.test.ts @@ -1,6 +1,8 @@ +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' -import { dirname, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' import type { Plugin } from 'vite' import { createLayeredResolution, layersResolver } from '../src/resolve' @@ -70,6 +72,55 @@ describe('layersResolver', () => { }) }) + describe('#super — the explicit super() specifier', () => { + const brandHeader = fixture('brand/src/components/Header.vue') + const baseHeader = fixture('base/src/components/Header.vue') + + it('bare #super re-resolves the importer’s own path from the next-lower layer', () => { + expect(resolveId('#super', brandHeader)).toBe(baseHeader) + }) + + it('#super/ resolves any path from strictly below the importer’s layer', () => { + // the importer is NOT an override of Header — the implicit self-skip can't express this + expect(resolveId('#super/components/Header.vue', fixture('brand/src/main.ts'))).toBe(baseHeader) + }) + + it('never resolves to the importer’s own layer (strictly below, not first match)', () => { + expect(resolveId('#super/components/Header.vue', brandHeader)).toBe(baseHeader) + }) + + it('returns null from the lowest layer / for a path absent below', () => { + expect(resolveId('#super', baseHeader)).toBeNull() + // Footer.vue exists only in base — from a brand importer it resolves DOWN to base's copy… + expect(resolveId('#super/components/Footer.vue', brandHeader)).toBe(fixture('base/src/components/Footer.vue')) + // …but from base itself there is nothing beneath. + expect(resolveId('#super/components/Footer.vue', fixture('base/src/components/Footer.vue'))).toBeNull() + }) + + it('returns null when the importer is outside the layer stack (or absent)', () => { + expect(resolveId('#super')).toBeNull() + expect(resolveId('#super/components/Header.vue', '/somewhere/else/file.ts')).toBeNull() + }) + + it('preserves query suffixes on both forms', () => { + expect(resolveId('#super?raw', brandHeader)).toBe(`${baseHeader}?raw`) + expect(resolveId('#super/components/Header.vue?vue&type=style', brandHeader)).toBe(`${baseHeader}?vue&type=style`) + }) + + it('probes extensions/index like any layered id', () => { + expect(resolveId('#super/widgets/Card', brandHeader)).toBe(fixture('base/src/widgets/Card/index.ts')) + }) + + it('composes through a deep (3-layer) chain, one step down per layer', () => { + const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')] + const dp = layersResolver({ roots: deepRoots }) + const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`) + expect(callResolveId(dp, '#super', W('top'))).toBe(W('mid')) + expect(callResolveId(dp, '#super', W('mid'))).toBe(W('base')) + expect(callResolveId(dp, '#super', W('base'))).toBeNull() + }) + }) + it('returns null when nothing matches across layers', () => { expect(resolveId('@/components/Missing.vue')).toBeNull() }) @@ -87,6 +138,15 @@ describe('layersResolver', () => { expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here }) + it('the hook filter matches #super ids but not lookalikes', () => { + const h = plugin.resolveId as { filter: { id: RegExp } } + expect(h.filter.id.test('#super')).toBe(true) + expect(h.filter.id.test('#super/components/Header.vue')).toBe(true) + expect(h.filter.id.test('#super?raw')).toBe(true) + expect(h.filter.id.test('#superstition')).toBe(false) + expect(h.filter.id.test('@/components/Header.vue')).toBe(true) + }) + it('caches candidates (repeated resolveId is stable, served from cache)', () => { const p = layersResolver({ roots }) const rid = (id: string) => callResolveId(p, id) @@ -103,6 +163,15 @@ describe('createLayeredResolution (introspection core)', () => { expect(r.parse('#layers/base/x')).toBeNull() }) + it('parse() accepts #super/ as a pseudo-prefix (so devtools can show its candidate stack)', () => { + const r = createLayeredResolution({ roots }) + expect(r.parse('#super/components/Header.vue?raw')).toEqual({ + prefix: '#super/', + sub: 'components/Header.vue', + query: '?raw', + }) + }) + it('candidates() lists every matching file across layers, high→low', () => { const r = createLayeredResolution({ roots }) expect(r.candidates('components/Header.vue')).toEqual([ @@ -151,6 +220,78 @@ describe('createLayeredResolution (introspection core)', () => { expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue']) }) + describe('targeted invalidation (invalidateFile / invalidateDir)', () => { + // Real temp layers — invalidation is about reacting to FS changes, fixtures can't change. + let tmp: string + const setup = () => { + tmp = mkdtempSync(join(tmpdir(), 'vite-layers-inv-')) + const brandSrc = join(tmp, 'brand/src') + const baseSrc = join(tmp, 'base/src') + mkdirSync(join(baseSrc, 'components'), { recursive: true }) + mkdirSync(join(brandSrc, 'components'), { recursive: true }) + writeFileSync(join(baseSrc, 'components/Button.ts'), 'export default 1') + return { brandSrc, baseSrc, r: createLayeredResolution({ roots: [brandSrc, baseSrc] }) } + } + afterEach(() => rmSync(tmp, { recursive: true, force: true })) + + it('a new file in a higher layer changes the winner after invalidateFile', () => { + const { brandSrc, baseSrc, r } = setup() + expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) // cached + const override = join(brandSrc, 'components/Button.ts') + writeFileSync(override, 'export default 2') + // control: the stale cache still serves the old winner… + expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) + r.invalidateFile(override) + // …and the targeted invalidation flips it (extension-probe sub `components/Button` was dropped) + expect(r.resolveId('@/components/Button')).toBe(toPosix(override)) + }) + + it('leaves unrelated cache entries warm and ignores files outside every root', () => { + const { brandSrc, baseSrc, r } = setup() + writeFileSync(join(baseSrc, 'components/Other.ts'), 'export default 3') + r.resolveId('@/components/Other') + const before = r.candidates('components/Other') + r.invalidateFile(join(brandSrc, 'components/Button.ts')) // different sub + r.invalidateFile(join(tmp, 'elsewhere/file.ts')) // outside every root — no-op + expect(r.candidates('components/Other')).toBe(before) // same array identity → still cached + }) + + it('a deleted file falls back to the lower layer after invalidateFile', () => { + const { brandSrc, baseSrc, r } = setup() + const override = join(brandSrc, 'components/Button.ts') + writeFileSync(override, 'export default 2') + expect(r.resolveId('@/components/Button')).toBe(toPosix(override)) + unlinkSync(override) + r.invalidateFile(override) + expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) + }) + + it('invalidateFile on an index file also drops the bare-dir and dir/index subs', () => { + const { brandSrc, baseSrc, r } = setup() + mkdirSync(join(baseSrc, 'widgets/Card'), { recursive: true }) + writeFileSync(join(baseSrc, 'widgets/Card/index.ts'), 'export default 1') + expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(baseSrc, 'widgets/Card/index.ts'))) + const override = join(brandSrc, 'widgets/Card/index.ts') + mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true }) + writeFileSync(override, 'export default 2') + r.invalidateFile(override) + expect(r.resolveId('@/widgets/Card')).toBe(toPosix(override)) + }) + + it('invalidateDir drops everything under the dir including its own index sub', () => { + const { brandSrc, baseSrc, r } = setup() + mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true }) + writeFileSync(join(brandSrc, 'widgets/Card/index.ts'), 'export default 2') + expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(brandSrc, 'widgets/Card/index.ts'))) + expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) + rmSync(join(brandSrc, 'widgets'), { recursive: true }) + r.invalidateDir(join(brandSrc, 'widgets')) + expect(r.resolveId('@/widgets/Card')).toBeNull() // dir gone, nothing below + const warm = r.candidates('components/Button') + expect(r.candidates('components/Button')).toBe(warm) // unrelated entry untouched + }) + }) + it('the plugin and a shared resolution stay in sync', () => { const shared = createLayeredResolution({ roots, record: 10 }) const plugin = layersResolver(shared) diff --git a/vite-layers/test/tsconfig.test.ts b/vite-layers/test/tsconfig.test.ts index ac18110..d2843b9 100644 --- a/vite-layers/test/tsconfig.test.ts +++ b/vite-layers/test/tsconfig.test.ts @@ -107,6 +107,32 @@ describe('generateTsConfig', () => { expect(r.dts).toContain(`declare module '#feature'`) }) + it('maps #super/* to the layers BELOW the project layer (explicit super() is typed)', async () => { + const { tsconfig } = await generateTsConfig(fixture('stack/app')) + const paths = tsconfig.compilerOptions!.paths as Record + // project layer (app) excluded — super() never resolves to the importer's own layer + expect(paths['#super/*']).toEqual(['../../base/src/*', '../../core/src/*']) + }) + + it('omits #super/* for a single-layer stack (paths forbids empty substitution arrays)', async () => { + const stack = { + merged: {}, + layers: [{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'app', config: {} }], + } + const { tsconfig } = await generateTsConfig(fixture('stack/app'), { stack: stack as never }) + expect(tsconfig.compilerOptions!.paths).not.toHaveProperty('#super/*') + }) + + it('emits an ambient super.d.ts so a bare `import "#super"` type-checks', async () => { + const r = await generateTsConfig(fixture('stack/app')) + expect(r.tsconfig.include).toContain('./super.d.ts') + expect(r.superDtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/super\.d\.ts$/) + expect(r.superDts).toContain(`declare module '#super' {}`) + // must stay a SCRIPT file: a top-level import would turn `declare module` into an augmentation + // of an unresolvable specifier (TS2664) + expect(r.superDts).not.toMatch(/^\s*(import|export)\b/m) + }) + it('maps #feature to the macro entry so tsc resolves the feature() import', async () => { const { tsconfig } = await generateTsConfig(fixture('stack/app')) const paths = tsconfig.compilerOptions!.paths as Record diff --git a/vue-sync-engine/HOW_IT_WORKS.md b/vue-sync-engine/HOW_IT_WORKS.md new file mode 100644 index 0000000..fa3c966 --- /dev/null +++ b/vue-sync-engine/HOW_IT_WORKS.md @@ -0,0 +1,360 @@ +# Как устроен vue-sync-engine + +Это объяснение «на пальцах»: что происходит внутри библиотеки от вызова +`useQuery()` до перерисовки компонента. Для справочника по API см. +[README.md](./README.md) — здесь фокус на механике и картинках. + +## Зачем он вообще нужен + +В обычном SPA каждый компонент сам решает, откуда брать данные: сам +дёргает `fetch`, сам хранит результат в `ref`, сам решает, когда обновить. +Если один и тот же пост показан в двух местах экрана — либо оба компонента +независимо грузят его заново, либо после мутации один обновился, а второй +остался со старыми данными. + +vue-sync-engine убирает эту проблему через одну идею: **все данные живут +в одном месте, компоненты только на них подписываются.** Само место — +не «дерево ответов API», а плоский нормализованный кэш сущностей, как +таблицы в базе данных (в духе Apollo / RTK Query), а не как в наивном +`fetch`-кэше, где один и тот же пользователь может быть продублирован +внутри трёх разных ответов запросов. + +## Главная идея одной картинкой + +Библиотека всегда состоит из двух половин, даже если физически они +работают в одном JS-потоке: **вкладка** (то, что видит пользователь) и +**QueryGraph** — «мини-сервер», который решает, что и когда фетчить. +Между ними — заменяемый транспорт. + +```mermaid +flowchart TB + subgraph TAB["📄 Вкладка браузера"] + direction TB + UI["Vue-компонент"] + HOOKS["useQuery / useMutation /\nuseInfiniteQuery / useEntity"] + RUNTIME["TabRuntime"] + MIRROR["Mirror\n(entities + query-state, ShallowRef)"] + UI --> HOOKS --> RUNTIME + RUNTIME <--> MIRROR + MIRROR -.-> UI + end + + TRANSPORT{{"Transport\nInline (queueMicrotask) или\nSharedWorker (MessagePort)"}} + + subgraph GRAPH["⚙️ QueryGraph — «мини-сервер» (тот же поток или SharedWorker)"] + direction TB + NODES["QueryNode-ы:\nдедуп fetch-ей, staleTime/gcTime,\nentityRefs"] + QUEUE["Очередь мутаций:\noptimistic → persist → retry/rollback"] + STORE[("StorageAdapter\nIndexedDB / память")] + NODES --> STORE + QUEUE --> STORE + end + + RUNTIME -->|"Subscribe / Unsubscribe\nMutate / FetchNextPage"| TRANSPORT + TRANSPORT -->|"QueryPatch / EntityPatch\nMutateResult"| RUNTIME + TRANSPORT --> NODES + TRANSPORT --> QUEUE +``` + +Ключевая мысль: **вкладка никогда не фетчит данные сама.** Она только +посылает «хочу подписаться на такой-то запрос» и получает в ответ поток +патчей. Реальный `fetch()` живёт только в QueryGraph. + +## Действующие лица + +| Кто | Что это простыми словами | +|---|---| +| **Entity** | Тип сущности в кэше — «таблица» (`post`, `user`). Описывает только, как достать `id` у объекта, и опционально — где его персистить. | +| **Query / InfiniteQuery** | Описание запроса: как построить ключ кэша из аргументов, как зафетчить, как разложить ответ на сущности (`normalize`). | +| **Mutation** | Запись: `fetch` + опциональные `optimistic` (мгновенная правка) и `onSuccess` (правка после ответа) + `invalidate` (что перефетчить). | +| **Mirror** | Реактивный «слепок» на стороне вкладки: сущности по типам + состояния запросов. Единственное, что реально читают компоненты. | +| **TabRuntime** | Клиентская логика вкладки: подписки (с дедупом по хэшу ключа), их GC, отправка мутаций, разбор входящих патчей. | +| **QueryGraph** | Серверная логика: хранит `QueryNode` на каждый уникальный запрос, дедуплицирует fetch, гидрирует из storage, рассылает патчи всем подписчикам. | +| **Transport** | Канал сообщений между вкладкой и QueryGraph. Две реализации: `Inline` (тот же поток, батчинг через `queueMicrotask`) и `SharedWorker` (через `MessagePort`). | +| **StorageAdapter / KeyedStore** | Персистентность. Два независимых уровня — см. [раздел ниже](#persistence-два-независимых-уровня). | + +## Что происходит по шагам: от `useQuery()` до рендера + +```mermaid +sequenceDiagram + autonumber + participant C as Компонент + participant TR as TabRuntime + participant M as Mirror + participant QG as QueryGraph + participant S as Storage + + C->>TR: useQuery(usersQuery, args) + TR->>M: ensureQuery(subId) → status: idle + TR->>QG: Subscribe(subId, defName, args) + Note over QG: ensureNode() — находит или создаёт QueryNode по hash(key(args)) + + alt узел новый и в storage есть валидный снапшот + QG->>S: queries.read(key) + entities.readMany() + S-->>QG: QuerySnapshot + сущности + QG->>TR: EntityPatch (восстановленные сущности) + QG->>TR: QueryPatch(status: success, cached result) + end + + opt данных нет или они устарели (age > staleTime) + QG->>TR: QueryPatch(status: pending) + TR->>M: applyQueryPatch → status: pending + M-->>C: isLoading = true + + QG->>QG: fetch(args) → normalize(response) + QG->>S: сохранить QuerySnapshot + сущности + QG->>TR: EntityPatch (новые/обновлённые сущности) + QG->>TR: QueryPatch(status: success, result) + end + + TR->>M: applyEntityPatches + applyQueryPatch + M-->>C: data / status обновились → компонент перерисовался +``` + +Важные детали, которые не видны в коде компонента: + +- **Дедупликация по ключу.** `subscribeQuery` хэширует `key(args)` + (`hashKey`, стабильная сериализация — порядок полей объекта не важен) и + ищет уже существующую подписку. Если два компонента одновременно + вызвали `useQuery(usersQuery, ...)` с одинаковыми аргументами — будет + один `QueryNode` и один fetch на двоих. +- **`isLoading` мигает и при фоновом рефетче.** Если данные уже есть, но + протухли (`age > staleTime`), QueryGraph сперва отдаёт кэш мгновенно, а + затем всё равно переводит статус в `pending` на время рефетча. Отдельного + флага `isFetching`/`isRefetching` в библиотеке нет — `isLoading` покрывает + оба случая: и первую загрузку, и фоновое обновление устаревших данных. +- **Протухание проверяется только при новой подписке.** Нет ни `setInterval`, + ни `visibilitychange`/`focus`-слушателей, которые бы сами дёргали рефетч + фонового запроса. Пока подписчик один и не размонтировался — застоявшиеся + данные просто лежат в кэше, пока кто-то не подпишется заново (например, + при возврате на страницу) или пока их явно не инвалидирует мутация. +- **GC-окно на отписку.** `onScopeDispose` вызывает `release()`, но реальная + отписка (`Unsubscribe` в QueryGraph) откладывается на `staleSubGcMs` + (по умолчанию 5 c). Это защита от «мигания»: быстрый переход между + вкладками/роутами не должен рвать подписку и гнать повторный fetch. + +## Нормализация: почему кэш плоский + +`normalize()` в определении запроса разбирает ответ API на **сущности** +(что идёт в общий кэш) и **result** (тонкая структура из id, которая +хранится именно в этом запросе). + +```mermaid +flowchart LR + RESP["Ответ API:\nPost {id:1, title, userId:5,\nauthor: {id:5, name...}}"] --> NORM["normalize(response)"] + NORM --> EPOST[("entities.post\n{1: {...}}")] + NORM --> EUSER[("entities.user\n{5: {...}}")] + NORM --> RES["result запроса\n{ids: [1]}"] +``` + +Почему это важнее, чем кажется: если пользователь `5` встречается ещё в +десяти других постах или в отдельном запросе `users.list`, это **один и тот +же объект в `entities.user`**, а не десять копий. `useEntity(UserEntity, 5)` +в любом компоненте — включая совсем не связанные с исходным запросом — +всегда прочитает актуальную версию. Оптимистичная мутация, которая +поправила имя пользователя, мгновенно видна везде, где он упомянут, без +ручной инвалидации каждого места. + +## Кэш и время жизни: `staleTime` и `gcTime` + +У каждого `QueryNode` есть простой жизненный цикл: + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Pending: первая подписка + Pending --> Success: fetch выполнен успешно + Pending --> Error: fetch завершился ошибкой + Success --> Pending: новая подписка застала данные протухшими (age > staleTime) + Success --> NoSubscribers: отписался последний подписчик + NoSubscribers --> Success: подписались снова до истечения gcTime + NoSubscribers --> [*]: gcTime истёк — узел и запись в storage удалены +``` + +Аналогия — молоко на полке магазина: + +- **`staleTime`** — «срок годности для доверия». Пока не истёк, новый + покупатель (подписчик) берёт с полки без вопросов, fetch не идёт. + По умолчанию 30 с. +- **`gcTime`** — «через сколько выбросить, если никто не берёт». Отсчёт + идёт с момента, когда отписался последний подписчик. Если до истечения + подписался кто-то новый — таймер просто отменяется. Если нет — узел и + соответствующая запись в `storage.queries` удаляются насовсем. + По умолчанию 5 мин. + +Оба значения задаются дефолтами при бутстрапе (`createEngine({ defaultStaleTime, defaultGcTime })`) и переопределяются на уровне конкретного `defineQuery(...)`. + +### Инвалидация + +Мутация может явно перевести чужие узлы обратно в `Pending`, указав теги +или сами деф-объекты в `invalidate`. Узлы без активных подписчиков в этот +момент просто помечаются протухшими — рефетч случится при следующей +подписке, а не сразу. + +## Мутации: мгновенный UI + автоматический откат + +Самая интересная часть. Когда вы вызываете `mutate(input)`, происходит +следующее: + +```mermaid +sequenceDiagram + autonumber + participant C as Компонент + participant TR as TabRuntime + participant QG as QueryGraph + participant Q as Очередь мутаций + participant API as Сервер + participant M as Mirror + + C->>TR: mutate({id, title}) + TR->>QG: Mutate(mutId, input) + QG->>Q: enqueue(mutId, input) + Q->>Q: optimistic(input, ctx) считает forward- и inverse-патчи + Q->>TR: EntityPatch (forward) — мгновенно + TR->>M: применить патч + M-->>C: UI обновился ДО ответа сервера + Q->>Q: persist в storage (переживёт перезагрузку страницы) + + Q->>API: fetch(input) + alt успех + API-->>Q: ответ сервера + Q->>Q: onSuccess(ctx) + invalidate(tags) + Q->>TR: MutateResult(ok: true) + TR-->>C: mutateAsync() resolve + else сетевая ошибка, есть ещё попытки + API-->>Q: ошибка сети + Q->>Q: остаётся pending, drain() повторит попытку позже + else ошибка, попытки исчерпаны (или ошибка не сетевая) + Q->>Q: rollback — применить inverse-патчи в обратном порядке + Q->>TR: EntityPatch (inverse) + TR->>M: откатить патч + M-->>C: UI вернулся к прежнему состоянию + Q->>TR: MutateResult(ok: false) + TR-->>C: mutateAsync() reject + end +``` + +Что стоит понимать про `optimistic`: + +```ts +optimistic: (input, ctx) => ctx.patchEntity(PostEntity, input.id, { title: input.title }) +``` + +Вызывая `patchEntity` / `upsertEntity` / `removeEntity`, вы не пишете +rollback руками. Движок сам на лету считает **инверсный патч** (было — +стало наоборот) и применяет его автоматически, если мутация в итоге +провалилась. + +### Очередь мутаций — она же офлайн-режим + +`QueuedMutation` пишется в `storage.mutations` **до** отправки запроса. +Это значит: + +- если вкладку закрыть/обновить посреди мутации — при следующем старте + движок подхватит незавершённые мутации из storage и продолжит попытки; +- retry идёт, только пока `navigator.onLine` и `attempts < maxRetries` + (по умолчанию 5); при возврате сети (`online`-событие) очередь сама + запускает `drain()`; +- сущности, тронутые ещё не завершённой мутацией, «запиниваются» + (`pinEntities`) — фоновая сборка мусора сущностей (`entityGc`) их не + тронет, пока мутация не разрешится. + +## Два режима движка + +Один и тот же `QueryGraph` можно поднять либо в том же потоке, что и UI, +либо в `SharedWorker`, общем на все вкладки одного origin'а. + +```mermaid +flowchart TB + subgraph INLINE["Inline — createEngine()"] + direction LR + T1["Вкладка"] --- QG1["QueryGraph\n(тот же JS-поток)"] + end + + subgraph SHARED["SharedWorker — createTabEngine()"] + direction LR + T2["Вкладка 1"] -->|MessagePort| SW["SharedWorker\nодин QueryGraph на все вкладки"] + T3["Вкладка 2"] -->|MessagePort| SW + T4["Вкладка 3"] -->|MessagePort| SW + end +``` + +| | Inline (`createEngine`) | SharedWorker (`createTabEngine`) | +|---|---|---| +| Кросс-таб синхронизация | нет | да, мгновенно | +| Дедупликация fetch | в пределах одной вкладки | глобально на все вкладки | +| IndexedDB | каждая вкладка открывает свою | один общий instance | +| Сложность подключения | минимальная | нужен отдельный worker-файл | + +Код компонентов и определения (`defineQuery` и т.д.) не меняются вообще — +разница только в том, как собран `TabRuntime` на старте приложения. + +## Persistence: два независимых уровня + +```mermaid +flowchart TB + QG["QueryGraph"] --> SA["StorageAdapter (уровень движка)"] + SA --> QS[("queries: QuerySnapshot\nрезультат + entityRefs")] + SA --> MQ[("mutations: QueuedMutation\nнезавершённые мутации")] + + QG --> KS["KeyedStore (уровень сущности, опционально)"] + KS --> E1[("PostEntity → idbStore")] + KS --> E2["UserEntity → без storage, только память"] +``` + +1. **Уровень движка** (`StorageAdapter`, `memoryAdapter()` или + `indexedDBAdapter({ dbName })`) — хранит снапшоты результатов запросов + и очередь мутаций. Без него движок работает так же, но всё исчезает + при перезагрузке страницы. +2. **Уровень сущности** (`KeyedStore` в `defineEntity({ storage })`) — + каждый тип сущности сам решает, персистится ли он, независимо от + остальных. В демо `PostEntity` живёт в IndexedDB, а `UserEntity` — + только в памяти, специально для контраста. + +Оба уровня работают вместе: снапшот запроса хранит только `entityRefs` +(ссылки `{type, id}`), а сами данные сущностей при гидрации подтягиваются +из своего `KeyedStore`. Если у типа сущности нет `storage` и её нет в +памяти воркера — гидрация признаётся неудачной, снапшот выбрасывается, и +при следующей подписке всё просто перефетчится заново. + +## Автодискавери определений через Vite-плагин + +Вместо того чтобы руками собирать массивы `entities`/`queries`/`mutations`, +можно раскидать `defineEntity`/`defineQuery`/`defineMutation` по файлам +`*.defs.ts` и один раз подключить плагин: + +```ts +syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] }) +``` + +Плагин сканирует файлы по glob-маске и собирает всё найденное в один +виртуальный модуль `virtual:sync-engine-registry`. Дедуп — по `name`: если +один и тот же деф случайно экспортирован из двух мест, плагин молча +оставит первый найденный. + +## Vue DevTools + +`installEngine(app, runtime)` в dev-режиме сама подключает кастомную +панель «Sync Engine» с пятью узлами: **Engine** (дефолты, счётчики), +**Queries** (статус/tags/cache-метаданные по каждой подписке), **Entities** +(персистентные vs in-memory, список инстансов), **Mutations** (кольцевой +буфер последних 50) и **Tabs** (обнаружение других вкладок через отдельный +`BroadcastChannel`). В продакшене весь код вырезается через константу +`__SYNC_ENGINE_DEV__`. + +## Шпаргалка: что за что отвечает в коде + +| Файл | Отвечает за | +|---|---| +| [`createEngine.ts`](./lib/src/createEngine.ts) | Точки входа: `createEngine` / `createTabEngine` / `bootstrapWorker` / `installEngine` | +| [`define.ts`](./lib/src/define.ts) | Фабрики `defineEntity` / `defineQuery` / `defineInfiniteQuery` / `defineMutation`, `Object.freeze` | +| [`tab/mirror.ts`](./lib/src/tab/mirror.ts) | Реактивный кэш вкладки: сущности по типам + состояния запросов, `ShallowRef` на каждую сущность отдельно | +| [`tab/runtime.ts`](./lib/src/tab/runtime.ts) | `TabRuntime`: дедуп подписок по хэшу ключа, GC-таймер отписки, `mutate()` | +| [`worker/queryGraph.ts`](./lib/src/worker/queryGraph.ts) | «Сервер»: `QueryNode`-ы, дедуп fetch-ей, гидрация из storage, инвалидация, entity-refcounting | +| [`worker/mutationQueue.ts`](./lib/src/worker/mutationQueue.ts) | Очередь мутаций: optimistic → persist → retry → rollback | +| [`core/patches.ts`](./lib/src/core/patches.ts) | `applyPatch` + автогенерация инверсных патчей для rollback | +| [`core/queryKey.ts`](./lib/src/core/queryKey.ts) | `hashKey()` — стабильная сериализация ключа (порядок полей объекта не важен) | +| [`transport/InlineTransport.ts`](./lib/src/transport/InlineTransport.ts) | Транспорт в одном потоке, батчинг через `queueMicrotask` | +| [`transport/SharedWorkerTransport.ts`](./lib/src/transport/SharedWorkerTransport.ts) | Транспорт через `MessagePort` поверх `SharedWorker` | +| [`adapters/`](./lib/src/adapters/) | `memoryAdapter` / `indexedDBAdapter` (движок), `idbStore` / `memoryStore` / `noopStore` (сущности) | +| [`composables/`](./lib/src/composables/) | Vue-обвязка: `useQuery` / `useMutation` / `useInfiniteQuery` / `useEntity` / `useEngine` |