mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
chore(cordis): adopt Include 1.1.1 journal alongside Loader rc.8
This commit is contained in:
@@ -656,10 +656,9 @@ export function renderConfigDump(
|
||||
const base = parsed as Parameters<typeof applyEntryPatches>[0]
|
||||
// snapshot_k = ONE application of layers 1..k flattened, using the exact
|
||||
// arguments boot passes for that prefix. snapshot_N is the mounted composition.
|
||||
// The patches are cloned per call: applyEntryPatches detaches the entry
|
||||
// list but pushes `insert` rows by reference from the patch list, so
|
||||
// sharing patch objects across snapshot calls would leak a later
|
||||
// snapshot's mutations into an earlier one's result.
|
||||
// The patches are cloned per call: applyEntryPatches clones entries and
|
||||
// inserted rows but assigns override values such as `config` by reference,
|
||||
// so each snapshot keeps its own copies of those values.
|
||||
const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
|
||||
const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
|
||||
return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
|
||||
@@ -804,8 +803,8 @@ export interface FailLoudProcess {
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
|
||||
// exact reasons already folded into the boot diagnostic visible through the
|
||||
// An activation rejection already folded into the boot diagnostic can also
|
||||
// reach the process rejection handler. Keep exact reasons visible through the
|
||||
// next process rejection checkpoint so the process guard can coalesce them.
|
||||
const assembledActivationRejections = new Map<unknown, number>()
|
||||
|
||||
|
||||
@@ -239,11 +239,10 @@ describe('real Loader composition', () => {
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
expect(ctx.get('directoryPicker')).toBeUndefined()
|
||||
// Self-disposing an include-tree entry persists `disabled: true` (loader
|
||||
// behavior, not the chooser's); await that debounced write so it cannot
|
||||
// race the temp-dir removal, and pin that the persisted row is the
|
||||
// chooser itself — the resolved backend still never reaches the file.
|
||||
// stop() drains the Include write queue, so this assertion does not depend
|
||||
// on the debounce timer racing Windows coverage load.
|
||||
// behavior, not the chooser's); await that write so it cannot race the
|
||||
// temp-dir removal, and pin that the persisted row is the chooser itself —
|
||||
// the resolved backend still never reaches the file. stop() drains the
|
||||
// Include journal, so this assertion does not depend on write timing.
|
||||
await includeTree(ctx).stop()
|
||||
expect(await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
|
||||
@@ -299,7 +298,7 @@ describe('real Loader composition', () => {
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
// Same self-dispose persistence as above: drain the Include write queue
|
||||
// Same self-dispose persistence as above: drain the Include journal
|
||||
// deterministically before asserting the persisted row.
|
||||
await includeTree(ctx).stop()
|
||||
expect(await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||
@@ -308,17 +307,20 @@ describe('real Loader composition', () => {
|
||||
expect(renameControl.attempts).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => {
|
||||
it('reports unsaved changes at shutdown and clears the error after a successful retry', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
const { ctx } = await loadComposition('127.0.0.1')
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
const include = includeTree(ctx)
|
||||
renameControl.failureCode = 'EIO'
|
||||
renameControl.remainingFailures = 1
|
||||
renameControl.remainingFailures = Infinity
|
||||
|
||||
await autoEntry.fiber!.dispose()
|
||||
await expect.poll(() => renameControl.injectedFailures).toBe(1)
|
||||
await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' })
|
||||
renameControl.remainingFailures = 0
|
||||
await expect(include.flush()).resolves.toBeUndefined()
|
||||
await expect(include.flush()).resolves.toBeUndefined()
|
||||
await expect(ctx.fiber.dispose()).resolves.not.toThrow()
|
||||
context = undefined
|
||||
})
|
||||
|
||||
@@ -73,7 +73,8 @@ it('does not replace the declaring Loader entry subtree or persist runtime tree
|
||||
expect(mountedCompositionRows(mount.tree)[0]!.entryId).toBe('child')
|
||||
expect(entry.subtree).toBe(previousTree)
|
||||
expect(entry.subgroup).toBe(previousGroup)
|
||||
mount.tree.commit({ id: 'missing', group: mount.tree.root })
|
||||
const row = mount.tree.root.data[0]!
|
||||
mount.tree.commit({ id: 'child', group: mount.tree.root, legacy: { ...row }, options: row })
|
||||
entry.subtree = mount.tree
|
||||
entry.subgroup = mount.tree.root
|
||||
const other = createScope(owner, {})
|
||||
|
||||
@@ -114,8 +114,11 @@ it('waits for injected consumer teardown, persists its final event, and releases
|
||||
expect(persisted.at(-1)).toMatchObject({ type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
|
||||
await consumingEntry.fiber!.dispose()
|
||||
await provider.init()
|
||||
// Self-disposal records disabled: true; re-enable through Loader so the journal agrees with the runtime.
|
||||
await ctx.loader.update(provider.id, { disabled: false })
|
||||
await (provider.parent.tree as Include).flush()
|
||||
await provider.fiber!.await()
|
||||
expect(provider.disabled).toBe(false)
|
||||
const reopened = await ctx.sessionPersistence.open(live.id, 'write')
|
||||
try {
|
||||
expect((await reopened.read()).events).toEqual(live.snapshotEvents())
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/** File reconciliation and persistence results through real Loader entries. */
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, FiberState } from '@deepseek-ai/cordis'
|
||||
import Loader, { Group, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
const row = (id: string, value: number): EntryOptions => ({ id, name: 'cordis:example', config: { value } })
|
||||
|
||||
// File rows may omit ids; Include assigns them.
|
||||
async function fixture(data: Array<Omit<EntryOptions, 'id'> & { id?: string }>) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-include-journal-'))
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true, maxRetries: 3 })
|
||||
})
|
||||
ctx.baseUrl = pathToFileURL(dir).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
ctx.loader.builtins.group = Group
|
||||
ctx.loader.builtins.example = () => {}
|
||||
const filename = join(dir, 'root.json')
|
||||
await writeFile(filename, JSON.stringify(data))
|
||||
const id = await ctx.loader.create({ name: 'cordis:include', config: { path: './root.json' } })
|
||||
await ctx.loader.await()
|
||||
const include = ctx.loader.resolve(id).subtree as Include
|
||||
const read = async () => JSON.parse(await readFile(filename, 'utf8')) as EntryOptions[]
|
||||
const edit = (entries: EntryOptions[]) => writeFile(filename, JSON.stringify(entries))
|
||||
const update = (id: string, value: number) => include.update(id, { config: { value } })
|
||||
const value = (id: string) => (include.store[id]!.fiber!.effectiveConfig as { value: number }).value
|
||||
return { ctx, dir, filename, id, include, read, edit, update, value }
|
||||
}
|
||||
|
||||
it.each(['refresh-first', 'update-first'] as const)('merges independent file and runtime edits with %s ordering', async (order) => {
|
||||
const app = await fixture([row('a', 1), row('b', 1)])
|
||||
await app.edit([row('a', 1), row('b', 3)])
|
||||
if (order === 'refresh-first') await Promise.all([app.include.refresh(), app.update('a', 2)])
|
||||
else await Promise.all([app.update('a', 2), app.include.refresh()])
|
||||
await app.include.flush()
|
||||
expect(app.value('a')).toBe(2)
|
||||
expect(app.value('b')).toBe(3)
|
||||
expect(await app.read()).toEqual([row('a', 2), row('b', 3)])
|
||||
})
|
||||
|
||||
it('lets the file win a conflicting config edit and reports the conflict', async () => {
|
||||
const app = await fixture([row('a', 1)])
|
||||
const error = vi.spyOn(app.include.ctx.logger, 'error')
|
||||
await app.edit([row('a', 3)])
|
||||
await app.update('a', 2)
|
||||
await app.include.flush()
|
||||
expect(app.value('a')).toBe(3)
|
||||
expect(await app.read()).toEqual([row('a', 3)])
|
||||
expect(error.mock.calls.some(([message]) => String(message).includes('config conflict'))).toBe(true)
|
||||
})
|
||||
|
||||
it('retains unsaved edits across a missing or invalid file and clears failure after recovery', async () => {
|
||||
const app = await fixture([row('a', 1)])
|
||||
await rm(app.filename)
|
||||
await app.update('a', 2)
|
||||
await expect(app.include.flush()).rejects.toThrow('unsaved change')
|
||||
await expect(readFile(app.filename)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(app.value('a')).toBe(2)
|
||||
await writeFile(app.filename, '{')
|
||||
await expect(app.include.refresh()).rejects.toThrow('failed to parse')
|
||||
expect(await readFile(app.filename, 'utf8')).toBe('{')
|
||||
await app.edit([row('a', 1), row('b', 1)])
|
||||
await app.include.refresh()
|
||||
await expect(app.include.flush()).resolves.toBeUndefined()
|
||||
expect(await app.read()).toEqual([row('a', 2), row('b', 1)])
|
||||
})
|
||||
|
||||
it('routes patch-owned fields and inserted rows into the parent file', async () => {
|
||||
const app = await fixture([])
|
||||
const inner = join(app.dir, 'inner.json')
|
||||
const original = JSON.stringify([row('a', 1)])
|
||||
await writeFile(inner, original)
|
||||
const id = await app.include.create({ name: 'cordis:include', config: {
|
||||
path: './inner.json', patches: [{ id: 'a', disabled: true }, { insert: [row('b', 1)] }],
|
||||
} })
|
||||
await app.ctx.loader.await()
|
||||
const child = app.ctx.loader.resolve(id).subtree as Include
|
||||
await child.update('a', { disabled: false })
|
||||
await child.update('b', { config: { value: 2 } })
|
||||
await child.flush()
|
||||
await app.include.flush()
|
||||
expect(await readFile(inner, 'utf8')).toBe(original)
|
||||
expect((await app.read())[0]!.config).toEqual({ path: './inner.json', patches: [
|
||||
{ id: 'a', disabled: false }, { insert: [row('b', 2)] },
|
||||
] })
|
||||
expect(child.store.a!.fiber!.effectiveConfig).toEqual({ value: 1 })
|
||||
expect(child.store.b!.fiber!.effectiveConfig).toEqual({ value: 2 })
|
||||
})
|
||||
|
||||
// Windows does not implement POSIX write permission bits.
|
||||
it.skipIf(process.platform === 'win32')('retains read-only file changes until a writable retry succeeds', async () => {
|
||||
const app = await fixture([row('a', 1)])
|
||||
try {
|
||||
await chmod(app.filename, 0o444)
|
||||
await app.update('a', 2)
|
||||
await expect(app.include.flush()).rejects.toThrow('unsaved change')
|
||||
expect(await app.read()).toEqual([row('a', 1)])
|
||||
expect(app.value('a')).toBe(2)
|
||||
} finally {
|
||||
await chmod(app.filename, 0o644)
|
||||
}
|
||||
await app.include.flush()
|
||||
expect(await app.read()).toEqual([row('a', 2)])
|
||||
})
|
||||
|
||||
it('preserves file text on file-driven removal and drains runtime edits before shutdown', async () => {
|
||||
const app = await fixture([row('a', 1), row('b', 1)])
|
||||
const edited = JSON.stringify([row('a', 1)], null, 4) + '\n'
|
||||
await writeFile(app.filename, edited)
|
||||
await app.include.refresh()
|
||||
expect(app.include.store.b).toBeUndefined()
|
||||
expect(await readFile(app.filename, 'utf8')).toBe(edited)
|
||||
await app.update('a', 2)
|
||||
await app.include.stop()
|
||||
expect(await app.read()).toEqual([row('a', 2)])
|
||||
})
|
||||
|
||||
it('keeps persisted runtime edits when the Include config changes', async () => {
|
||||
const app = await fixture([row('a', 1)])
|
||||
await app.include.update('a', { disabled: true })
|
||||
await app.include.flush()
|
||||
await app.ctx.loader.update(app.id, { config: { path: './root.json', enableLogs: true } })
|
||||
await app.include.flush()
|
||||
expect(app.include.store.a!.fiber?.state).not.toBe(FiberState.ACTIVE)
|
||||
expect(await app.read()).toEqual([{ ...row('a', 1), disabled: true }])
|
||||
})
|
||||
|
||||
it('keeps generated ids and fibers for anonymous file and inserted rows across updates', async () => {
|
||||
const app = await fixture([{ name: 'cordis:example', config: { value: 1 } }])
|
||||
const config = { path: './root.json', patches: [{ insert: [{ name: 'cordis:example', config: { value: 2 } }] }] }
|
||||
await app.ctx.loader.update(app.id, { config })
|
||||
await app.include.flush()
|
||||
const fibers = () => Object.values(app.include.store).map(entry => entry.fiber)
|
||||
const before = fibers()
|
||||
expect(before).toHaveLength(2)
|
||||
await app.ctx.loader.update(app.id, { config: { ...config, enableLogs: true } })
|
||||
await app.include.flush()
|
||||
await app.include.refresh()
|
||||
expect(fibers()).toEqual(before)
|
||||
expect(fibers().every((fiber, index) => fiber === before[index])).toBe(true)
|
||||
})
|
||||
|
||||
it('persists a row moved into the Include from another tree as a complete creation', async () => {
|
||||
const app = await fixture([{ id: 'g', name: 'cordis:group', group: true, config: [] }])
|
||||
const id = await app.ctx.loader.create({ name: 'cordis:example', config: { value: 1 } })
|
||||
await app.ctx.loader.await()
|
||||
await app.ctx.loader.update(id, { config: { value: 2 } }, `${app.id}:g`)
|
||||
await app.include.flush()
|
||||
expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [
|
||||
{ id, name: 'cordis:example', config: { value: 2 } },
|
||||
] }])
|
||||
})
|
||||
@@ -304,6 +304,7 @@ it('updates volatile children through Group and Include custom updates and saves
|
||||
expect(initial.title.get()).toBe('group-update')
|
||||
expect(group.options.config).toEqual([child('group-update')])
|
||||
await includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] })
|
||||
await include.flush()
|
||||
await ctx.loader.await()
|
||||
expect(initial.title.get()).toBe('include-update')
|
||||
expect(includeEntry.fiber).toBe(originalInclude)
|
||||
|
||||
Vendored
+8
-9
@@ -18,7 +18,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac
|
||||
| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` |
|
||||
| `cordis/` | `@deepseek-ai/cordis` | `cordis` | 4.0.0-rc.11 | https://github.com/cordiverse/cordis (`packages/core`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
|
||||
| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.8 | https://github.com/cordiverse/cordis (`packages/loader`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
|
||||
| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.1.1 | https://github.com/cordiverse/cordis (`packages/include`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
|
||||
| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.4 | https://github.com/cordiverse/cordis (`packages/timer`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
|
||||
| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
@@ -30,27 +30,26 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis
|
||||
|
||||
## Local modifications
|
||||
|
||||
Keep this log exhaustive — every divergence from upstream must be listed. Each entry has a stable ID: remove an entry when its divergence no longer exists, and never renumber or reuse IDs. The next new entry is V26.
|
||||
Keep this log exhaustive — every divergence from upstream must be listed. Each entry has a stable ID: remove an entry when its divergence no longer exists, and never renumber or reuse IDs. The next new entry is V27.
|
||||
- **V1 · `hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions.
|
||||
- **V2 · All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. References within the vendored set use `workspace:~`. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes.
|
||||
- **V3 · All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
|
||||
- **V4 · Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
|
||||
- **V5 · `schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
|
||||
- **V6 · `cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
|
||||
- **V7 · Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. The upstream `EventsService.dispatch()` deprecation annotation is omitted: DSH enumerates callbacks to contain each observer failure independently, which the typed dispatch methods do not provide. Comment-only; no code changes. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
|
||||
- **V8 · `include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
|
||||
- **V7 · Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. The upstream `EventsService.dispatch()` deprecation annotation is omitted: DSH enumerates callbacks to contain each observer failure independently, which the typed dispatch methods do not provide. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
|
||||
- **V9 · `hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`.
|
||||
- **V10 · Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`).
|
||||
- **V11 · Include patch and YAML exports**: `applyEntryPatches` aliases the pure `patch.ts` implementation from Cordis `v4.0.0-rc.11`, and `entryListSchema` exposes the `!!js` YAML dialect for config dumps. Include imports `patch.ts` and its `journal.ts` helpers from the rc.11 source on top of its 1.0.4 baseline; its runtime keeps the existing writer (V14).
|
||||
- **V13 · `include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change.
|
||||
- **V14 · `include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
|
||||
- **V15 · Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker, writer and patches, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites.
|
||||
- **V11 · Include patch and YAML exports**: `include/src/index.ts` exports upstream `applyPatches` as `applyEntryPatches` and the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes entries with the same algorithm that mounting uses. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
|
||||
- **V14 · Include persistence results**: `include/src/index.ts` keeps an error for unsaved journal changes. `flush()` retries pending writes and rejects while changes remain unsaved; `stop()` returns the same result and always stops the child tree; a later successful flush clears previous errors. A failed `refresh()` can also reject while runtime changes remain unsaved. Patch-owned changes route to the parent Include, so callers that need them persisted must flush that parent too. Ordinary Fiber teardown keeps its error containment. Covered by `scripts/cordis-include-journal.spec.ts` and `packages/host/directory-picker-auto/tests/loader-composition.spec.ts`.
|
||||
- **V15 · Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker and public patch exports, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites.
|
||||
- **V16 · `cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
|
||||
- **V17 · `@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
|
||||
- **V22 · Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial<S>` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` takes the canonical fiber `entry.fiber.ctx.fiber` (`Entry.fiber` holds the registry wrapper), re-parses the raw config through `Fiber.resolveConfig()`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite.
|
||||
- **V23 · `loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field.
|
||||
- **V24 · `timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources.
|
||||
- **V25 · Loader and Include lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Include forwards `commit(change)` reports to its existing writer, and in-memory trees use no-op commits. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling.
|
||||
- **V25 · Loader lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling.
|
||||
- **V26 · `loader/src/config/tree.ts` cross-tree move reports**: `EntryTree.update()` reports a move into another tree as a removal to the source tree and a creation, without `legacy` or `from`, to the destination tree. Upstream reports the destination as an update with `legacy`, which the Include journal records as a partial row for an id that its file does not contain. Covered by `scripts/cordis-entry-commit.spec.ts`.
|
||||
|
||||
### Resolver adaptations
|
||||
|
||||
|
||||
Vendored
+7
-7
@@ -41,13 +41,13 @@ The identifiers below are plan labels, not GitHub issue numbers. Preconditions a
|
||||
| 3 | Upgrade Timer to 1.1.4, retaining DSH locale resources and build metadata. | 0 | Retain V24 (display metadata); no logged runtime patch retired. | Concurrent interval reads, throttle cancellation and void return types; check callers using the old Core. |
|
||||
| 4a | Upgrade Core to rc.11 with the minimum Loader, Include and HMR compatibility slice described below. | 1 | Retire V12 (activation observer), V21 (logger disposer) and, after canonical-fiber audits, V20 (Entry identity workaround). Partial V6 (lifecycle) and V15 (config model). Retain/adapt V22 and V23 (module namespace). | Config resolution, volatile identity, service isolation, dependent-before-provider teardown, retained effect regressions and application startup. |
|
||||
| 4b | Import Loader's project-relative bare-specifier resolver, runtime API detection and fallback when Node internals are absent. | 0 | Retire V19: upstream now detects the internal loader by its available APIs. | Resolve in the config project's package scope, retain subpaths, handle read-only helper creation and test Node 24's older v1 API. |
|
||||
| 4c | Complete Loader's `EntryChange` / `commit(change)` reporting and remaining changes together with upstream Include's pure patch implementation and journal helper module. Adapt the old writer and in-memory trees; keep `applyEntryPatches` and `entryListSchema` exports. | 4a, 4b | Retire V18 (`disabled` expressions). Partial V11: retire the duplicate patch algorithm and insert-index fix; retain public exports. Retain/adapt V22, including the schema receiver fix, and V23. | Create, move, remove and self-update reports; no false self-disposal writes; later patches address inserted rows; cloning and config dumps agree with mounting. The old Include writer remains active. |
|
||||
| 4c | Complete Loader's `EntryChange` / `commit(change)` reporting and remaining changes, and upgrade Include to its 1.1.1 journal implementation in the same PR. Both HMR providers keep finding Include through their existing scans; keep `applyEntryPatches` and `entryListSchema` exports and in-memory no-op commits. | 4a, 4b | Retire V18 (`disabled` expressions), V8 (old refresh implementation) and V13 (`writeTask` type). Partial V11: retire the duplicate patch algorithm and insert-index fix; retain public exports. Partial V14: replace the write queue; retain rejection while the journal contains unsaved changes. Retain/adapt V22, including the schema receiver fix, V23 and the V15 child-expression marker. | Create, move, remove and self-update reports; no false self-disposal writes; later patches address inserted rows; stable ids for anonymous entries; file/runtime conflict handling, patch-owned writes, shutdown results and nested expressions; cloning and config dumps agree with mounting. |
|
||||
| 4e | Complete vendor HMR's upgrade to 1.1.1, preserving required local path, readiness and explicit-watch behavior. | 2, 3, 4a | Partial V9: adopt upstream ordering and path handling where equivalent; retain explicit readiness and realpath gaps. Retain V1 (no YAML locale imports). | Three-stage module replacement, nested entries, disabled/removed entries, explicit watches and the existing local path cases. |
|
||||
| 4f | Upgrade Include to its journal implementation, retaining the explicit watch registration already adopted in PR 2. | 2, 4c | Retire V8 (old refresh implementation) and V13 (`writeTask` type). Partial V14: replace the write queue; decide unsaved-error reporting. Retain the remaining V11 exports and V15 child-expression marker. | File/runtime conflict handling, patch-owned writes, shutdown results, nested expressions and one coordinated refresh under either HMR provider. |
|
||||
| 4f | Register the journal Include through the explicit watch adopted in PR 2 and remove the remaining Include scans. | 2, 4c | Adapt V9's config-path matching to the journal Include. | One coordinated refresh per change under either HMR provider; profile watching unchanged. |
|
||||
|
||||
Shared integration differences remain in every affected sync: V2 (manifests), V3 (TypeScript projects), V4/V10 (imports and Node-compatible modules), V5 (build overrides), V7 (API documentation), V16 (published Core source) and V17 (scope). Untouched packages retain them without edits. V6 only loses the stale-epoch guard already supplied upstream; its remaining ownership and cleanup guarantees stay. V15 replaces the old raw/effective-config and event implementation, but the Include child-expression behavior remains local. V22 is not upstreamed by this release, including its schema-bound `simplify` call.
|
||||
|
||||
After PR 0, PRs 1, 2, 3 and 4b have no implementation dependency on one another. Core depends on configuration preparation; it does not depend on Timer. Loader completion and vendor HMR can proceed independently after Core. Include journal adoption depends on Loader and the complete watch migration, not on the new vendor HMR replacement algorithm; PRs 4e and 4f can merge in either order. No single GitHub stack should impose a dependency between unrelated branches. Shared files such as the vendor manifest may still require merge-conflict resolution.
|
||||
After PR 0, PRs 1, 2, 3 and 4b have no implementation dependency on one another. Core depends on configuration preparation; it does not depend on Timer. Loader completion and vendor HMR can proceed independently after Core. Include journal adoption is part of Loader completion; PR 4f only connects that Include to PR 2's explicit watches and does not depend on the new vendor HMR replacement algorithm, so PRs 4e and 4f can merge in either order. No single GitHub stack should impose a dependency between unrelated branches. Shared files such as the vendor manifest may still require merge-conflict resolution.
|
||||
|
||||
### Minimum Core compatibility slice
|
||||
|
||||
@@ -62,9 +62,9 @@ Module resolution, structured tree-change reporting, journal reconciliation and
|
||||
|
||||
### Temporary compatibility code
|
||||
|
||||
PR 4c changes tree mutation producers to `commit(change)` while the old Include remains installed. That Include can implement `commit(change)` by invoking its existing `write()` operation; it deliberately ignores the detailed change report until the journal replaces the writer. In-memory trees, including `PresetTree`, implement a no-op commit. This avoids maintaining two mutation-reporting protocols in Loader. PR 4f deletes the Include writer adapter and old queue.
|
||||
PR 4c changes tree mutation producers to `commit(change)` and adopts the journal Include in the same PR: upstream `patch.ts` clones file and inserted entries, while the old Include relied on sharing entry objects with the running tree for its cached data and generated ids. In-memory trees, including `PresetTree`, implement a no-op commit. Until PR 4f, both HMR providers find the journal Include through their existing scans and call its `refresh()`.
|
||||
|
||||
PR 4c imports `patch.ts` together with its `journal.ts` helpers and their Loader `EntryChange` dependency, even though Include does not start runtime journaling yet. The public patch export may remain an alias; it must not retain a second algorithm. Every temporary divergence has an owning introduction PR and a named removal PR. No runtime version detection or second Core event protocol is planned.
|
||||
The public patch export may remain an alias; it must not retain a second algorithm. Every temporary divergence has an owning introduction PR and a named removal PR. No runtime version detection or second Core event protocol is planned.
|
||||
|
||||
PR 2 moves Include refresh ownership in one step: both HMR providers expose `watch()`, the existing Include registers its callback, and both providers stop their equivalent Include scans. The upstream HMR source no longer performs that scan, so importing it before migrating the Include consumer would lose config refresh. Moving the complete watch concern first avoids a temporary scan adapter and makes the later module-replacement and persistence decisions independent.
|
||||
|
||||
@@ -73,8 +73,8 @@ PR 2 moves Include refresh ownership in one step: both HMR providers expose `wat
|
||||
| Decision | Owning PR | Proposal and unresolved choice |
|
||||
|---|---|---|
|
||||
| Active effective config | 1, 4a | Store or expose the object actually supplied to the current instance. Resolving a candidate through `internal/config` must not overwrite it. Decide the publication point and invalidation rules before switching Core; a WeakMap alone does not establish those rules. |
|
||||
| Persistence outcome | 4f | Prefer the journal's retry and reconciliation mechanism. Decide how explicit callers observe modifications still unsaved at shutdown. Do not require a historical failure to remain fatal after a successful retry merely because the old test expects it. |
|
||||
| Nested config interpolation | 4a, 4f | Preserve child-owned expression evaluation. Keep the existing Include marker initially; any refinement that permits interpolation of Include's own fields while excluding child lists is a separate behavior change. |
|
||||
| Persistence outcome | 4c | Use the journal's retry and reconciliation mechanism. Explicit `flush()` and `stop()` reject while changes remain unsaved; later successful retries clear previous errors. Keep this local adaptation minimal. |
|
||||
| Nested config interpolation | 4a, 4c | Preserve child-owned expression evaluation. Keep the existing Include marker initially; any refinement that permits interpolation of Include's own fields while excluding child lists is a separate behavior change. |
|
||||
| HMR replacement failure | 4e | Adopt upstream behavior for vendor HMR. Preserve DSH HMR's current source-reload rollback policy in this series; replacing that policy requires its own proposal and tests. |
|
||||
| Effect cleanup guarantees | 4a | Retain guarantees not supplied by rc.11. Further upstreaming or retirement is separate work, not a prerequisite for reaching the release baseline. |
|
||||
|
||||
|
||||
Vendored
+3
-1
@@ -46,4 +46,6 @@ Patches can insert entries or override fields on entries with a matching `id`.
|
||||
|
||||
`applyEntryPatches(data, patches, warn)` uses the same upstream algorithm as mounting. It clones file entries even without patches and clones inserted entry lists before indexing them, so later patches can address inserted rows without mutating those input lists. `entryListSchema` exposes Include’s YAML dialect to configuration tools. Nested entry expressions remain literal until their owning Loader entry evaluates them.
|
||||
|
||||
Tree mutations arrive through `commit(change)` and schedule the existing debounced writer. The imported journal helpers support patch ownership; runtime file reconciliation still uses `refresh()` and the existing write queue.
|
||||
Tree mutations arrive through `commit(change)` and enter a journal. Include merges them with the latest file before writing; file edits win conflicts on the same fields and produce a diagnostic. Patch-owned fields and inserted entries update the parent Include’s patches instead of the included file. Invalid, missing or read-only files leave unsaved changes in memory.
|
||||
|
||||
`flush()` waits for this Include’s pending file operations and tree reconciliation, retries outstanding writes, and rejects if changes remain unsaved. Successful persistence clears previous errors. `refresh()` rereads the file and exposes the same completion result. To persist patch-owned changes, flush the child and then its parent. `stop()` makes a final attempt and always stops child entries, even when it rejects; ordinary Fiber disposal still contains cleanup errors. File writes use an atomic rename with bounded retries for transient destination locks.
|
||||
|
||||
Vendored
+371
-156
@@ -1,11 +1,17 @@
|
||||
import { EntryGroup, EntryTree, isJsExpr, type EntryChange, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { type EntryChange, type EntryOptions, EntryTree, EntryGroup, isJsExpr } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { extname } from 'node:path'
|
||||
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { deepEqual } from '@deepseek-ai/cosmokit'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { access, constants, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { applyPatches, type PatchOptions } from './patch.ts'
|
||||
import { applyJournal, flatten, type Journal, merge, reconcile, record } from './journal.ts'
|
||||
import { applyPatches, ensureIds, ensureInsertIds, PatchIndex, type PatchOptions, routeJournal } from './patch.ts'
|
||||
/** Shared patch semantics used by config dumps and mounting. */
|
||||
export { applyPatches as applyEntryPatches } from './patch.ts'
|
||||
/** Serialized entry patch options. */
|
||||
export type { PatchOptions } from './patch.ts'
|
||||
|
||||
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
@@ -15,235 +21,444 @@ const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
represent: (data) => data['__jsExpr'],
|
||||
})
|
||||
|
||||
/**
|
||||
* The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
|
||||
* the Loader evaluates at entry activation. Exported so config tooling
|
||||
* (`dsh --dump-config`) parses and prints exactly the dialect this include
|
||||
* mounts.
|
||||
*/
|
||||
/** YAML dialect preserving child-owned JavaScript expression nodes. */
|
||||
export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)
|
||||
|
||||
const schema = entryListSchema
|
||||
|
||||
const writable: Record<string, string> = {
|
||||
const types: Record<string, string> = {
|
||||
'.json': 'application/json',
|
||||
'.yaml': 'application/yaml',
|
||||
'.yml': 'application/yaml',
|
||||
}
|
||||
|
||||
const supported = new Set(Object.keys(writable))
|
||||
type ConfigStage = 'read' | 'parse' | 'validate'
|
||||
|
||||
const WRITE_RETRY_LIMIT = 10
|
||||
const WRITE_RETRY_DELAY_MS = 50
|
||||
|
||||
function retryableWriteError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM'
|
||||
interface Snapshot {
|
||||
content: string
|
||||
data: EntryOptions[]
|
||||
}
|
||||
|
||||
/** Apply the upstream patch algorithm to detached entry data for mounting or config dumps. */
|
||||
export { applyPatches as applyEntryPatches } from './patch.ts'
|
||||
/** Serialized entry patch options. */
|
||||
export type { PatchOptions } from './patch.ts'
|
||||
export class ConfigFileError extends Error {
|
||||
name = 'ConfigFileError'
|
||||
|
||||
constructor(public stage: ConfigStage, path: string, cause: unknown) {
|
||||
super(`failed to ${stage} config file ${path}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
/** The file changed under us between the pre-check and the rename. */
|
||||
class StaleWriteError extends Error {
|
||||
name = 'StaleWriteError'
|
||||
}
|
||||
|
||||
interface Anonymous {
|
||||
parent: string | null
|
||||
options: EntryOptions
|
||||
}
|
||||
|
||||
function isENOENT(error: unknown) {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
const RENAME_RETRIES = 10
|
||||
const RENAME_BACKOFF = 20
|
||||
|
||||
/**
|
||||
* On Windows a rename over a file that another process holds open (typically
|
||||
* an antivirus scanning what we just wrote) fails with one of these; the lock
|
||||
* is normally gone within milliseconds. A bounded retry is cheap enough to
|
||||
* apply on every platform.
|
||||
*/
|
||||
function isTransientRenameError(error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||
return code === 'EACCES' || code === 'EPERM' || code === 'EBUSY'
|
||||
}
|
||||
|
||||
/** Config namespace for the file-backed include loader. */
|
||||
export namespace Include {
|
||||
/** Config for a file-backed loader subtree. */
|
||||
export interface Config {
|
||||
/** YAML or JSON path resolved from `ctx.baseUrl`. */
|
||||
path: string
|
||||
/** Entry list written when the file does not already exist. */
|
||||
initial?: any[]
|
||||
/** Runtime patches applied after reading the file. */
|
||||
patches?: PatchOptions[]
|
||||
/** Enables loader apply/reload/unload logs for this subtree. */
|
||||
enableLogs?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** Loader entry tree backed by a YAML or JSON file. */
|
||||
export class Include extends EntryTree {
|
||||
static inject = ['loader']
|
||||
|
||||
// Tree-carrier marker (the Group plugin declares the same): this config is
|
||||
// entry and patch lists, so the Loader's `internal/config` interpolation
|
||||
// keeps it literal — a `!!js` expression inside a nested row's config
|
||||
// belongs to that row's fiber, resolving lazily in the row's own context.
|
||||
// Include's own fields (`path`, `enableLogs`) therefore stay literal too.
|
||||
// Child entries evaluate their expressions against their own injections.
|
||||
static readonly [EntryGroup.key] = true
|
||||
|
||||
public filename: string
|
||||
private type?: string
|
||||
private readonly: boolean
|
||||
private content?: string
|
||||
private data?: EntryOptions[]
|
||||
private writeTask?: NodeJS.Timeout | undefined
|
||||
private pendingWrite?: EntryOptions[]
|
||||
private writeQueue: Promise<void> = Promise.resolve()
|
||||
private type: string
|
||||
|
||||
/** The last file state we read or wrote. */
|
||||
private cache?: Snapshot
|
||||
/** Tree-side changes that have not reached the file yet. */
|
||||
private journal: Journal = new Map()
|
||||
/** Ids we assigned to entries the file leaves anonymous. */
|
||||
private anonymous = new Map<string, Anonymous>()
|
||||
|
||||
private dirtyRead = false
|
||||
private dirtyWrite = false
|
||||
private _draining?: Promise<void>
|
||||
|
||||
/** Only the latest intended tree is ever applied; older ones are skipped. */
|
||||
private _pendingTree?: EntryOptions[]
|
||||
private _applyTask?: Promise<void>
|
||||
|
||||
private _disposed = false
|
||||
private _seq = 0
|
||||
private persistenceError: unknown
|
||||
|
||||
constructor(ctx: Context, public config: Include.Config) {
|
||||
super(ctx)
|
||||
this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
|
||||
this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
|
||||
const ext = extname(this.filename)
|
||||
if (!supported.has(ext)) {
|
||||
if (!types[ext]) {
|
||||
throw new Error(`extension "${ext}" not supported`)
|
||||
}
|
||||
this.type = writable[ext]
|
||||
this.readonly = !this.type
|
||||
this.type = types[ext]
|
||||
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
|
||||
ensureInsertIds(config.patches, id => !!this.store[id])
|
||||
|
||||
ctx.on('internal/update', (config, next) => {
|
||||
if (config.path !== this.config.path) return next()
|
||||
this.config = config
|
||||
return this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
|
||||
this.ctx.logger.warn('config update at %C failed', this.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
ensureInsertIds(config.patches, id => !!this.store[id])
|
||||
this._scheduleApply()
|
||||
return this._applyTask
|
||||
})
|
||||
}
|
||||
|
||||
private async checkAccess() {
|
||||
if (!this.type) return
|
||||
try {
|
||||
await access(this.filename, constants.W_OK)
|
||||
} catch {
|
||||
this.readonly = true
|
||||
}
|
||||
}
|
||||
|
||||
private async read(forced = false) {
|
||||
const content = await readFile(this.filename, 'utf8')
|
||||
if (!forced && this.content === content) return false
|
||||
let data: any
|
||||
if (this.type === 'application/yaml') {
|
||||
data = yaml.load(content, { schema: entryListSchema })
|
||||
} else if (this.type === 'application/json') {
|
||||
data = JSON.parse(content)
|
||||
} else {
|
||||
const module = await import(/* @vite-ignore */ this.filename)
|
||||
data = module.default || module
|
||||
}
|
||||
// An empty or truncated file (common mid-edit: editors and `sed -i` write
|
||||
// through temp states) parses to `undefined`, not an error; reject every
|
||||
// non-array shape here so callers see one "invalid file" signal. Content
|
||||
// and data commit only on success, so an edit that is later reverted to
|
||||
// the exact last good content correctly reads as "unchanged".
|
||||
if (!Array.isArray(data)) {
|
||||
throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
|
||||
}
|
||||
this.content = content
|
||||
this.data = data
|
||||
await this.checkAccess()
|
||||
return true
|
||||
}
|
||||
|
||||
private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
|
||||
return applyPatches(data, patches, (message, ...args) => {
|
||||
this.ctx.root.logger?.('loader').warn(message, ...args)
|
||||
})
|
||||
private warn = (format: string, ...args: any[]) => {
|
||||
this.ctx.root.logger?.('loader').warn(format, ...args)
|
||||
}
|
||||
|
||||
async* [Service.init]() {
|
||||
try {
|
||||
await this.read()
|
||||
await this._read()
|
||||
} catch (error) {
|
||||
// Only a missing file falls back to `initial` (or the not-found error):
|
||||
// an existing-but-invalid file must fail loud with its real parse error,
|
||||
// never be mislabelled as absent or silently overwritten.
|
||||
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
|
||||
if (this.config.initial) {
|
||||
await this._writeFile(this.config.initial as any)
|
||||
await this.read(true)
|
||||
} else {
|
||||
// only a missing file falls back to `initial`
|
||||
const cause = error instanceof ConfigFileError && error.stage === 'read' ? error.cause : undefined
|
||||
if (!isENOENT(cause)) throw error
|
||||
if (!this.config.initial) {
|
||||
throw new Error(`config file not found: ${this.filename}`)
|
||||
}
|
||||
const initial = structuredClone(this.config.initial) as EntryOptions[]
|
||||
ensureIds(initial, id => !!this.store[id])
|
||||
await this._writeText(this.dump(initial))
|
||||
await this._read()
|
||||
}
|
||||
|
||||
yield () => this.stop()
|
||||
await this.root.update(this.applyPatches(this.data!))
|
||||
await this._applyTask
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this._disposed = true
|
||||
this._pendingTree = undefined
|
||||
// Flush what the user last did before the tree goes away; this is also the
|
||||
// last attempt for changes a failed write left in the journal.
|
||||
if (this.journal.size) {
|
||||
this.dirtyWrite = true
|
||||
this._drain()
|
||||
}
|
||||
try {
|
||||
await this.flushWrite()
|
||||
await this.flush()
|
||||
} finally {
|
||||
this.root.stop()
|
||||
await this.flushWrite()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the file and refresh child entries when content changed. An
|
||||
* unreadable or unparsable file logs a warning and keeps the last good
|
||||
* tree: a hot-reload of a live app must never take the process down.
|
||||
* Resolves once every pending read and write has been processed and the
|
||||
* tree reflects the outcome. Retries pending writes and rejects while changes remain unsaved.
|
||||
*/
|
||||
async refresh() {
|
||||
try {
|
||||
if (!await this.read()) return
|
||||
await this.root.update(this.applyPatches(this.data!))
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
async flush() {
|
||||
if (this.journal.size && !this._draining) {
|
||||
this.dirtyWrite = true
|
||||
this._drain()
|
||||
}
|
||||
while (this._draining || this._applyTask) {
|
||||
await this._draining
|
||||
await this._applyTask
|
||||
}
|
||||
if (this.journal.size) {
|
||||
throw this.persistenceError ?? new Error(`config file ${this.filename} has ${this.journal.size} unsaved change(s)`)
|
||||
}
|
||||
this.persistenceError = undefined
|
||||
}
|
||||
|
||||
private async _writeFile(config: EntryOptions[]) {
|
||||
if (this.readonly) {
|
||||
throw new Error(`cannot overwrite readonly config`)
|
||||
}
|
||||
if (this.type === 'application/yaml') {
|
||||
this.content = yaml.dump(config, { schema })
|
||||
} else if (this.type === 'application/json') {
|
||||
this.content = JSON.stringify(config, null, 2)
|
||||
}
|
||||
await writeFile(this.filename + '.tmp', this.content!)
|
||||
for (let retry = 0; ; retry++) {
|
||||
try {
|
||||
await rename(this.filename + '.tmp', this.filename)
|
||||
return
|
||||
} catch (error) {
|
||||
if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error
|
||||
await delay((retry + 1) * WRITE_RETRY_DELAY_MS)
|
||||
/** Re-read the file and reconcile the tree with it. */
|
||||
refresh() {
|
||||
this.dirtyRead = true
|
||||
this._drain()
|
||||
return this.flush()
|
||||
}
|
||||
|
||||
/** Called synchronously by the loader once a tree-side change is in place. */
|
||||
commit(change: EntryChange) {
|
||||
this.context.emit('loader/config-update')
|
||||
const parent = change.group === this.root ? null : change.group.ctx.fiber.entry!.options.id
|
||||
record(this.journal, change, parent)
|
||||
this.dirtyWrite = true
|
||||
this._drain()
|
||||
}
|
||||
|
||||
private _drain() {
|
||||
if (this._draining) return
|
||||
this._draining = this._loop().finally(() => {
|
||||
this._draining = undefined
|
||||
})
|
||||
}
|
||||
|
||||
private async _loop() {
|
||||
while (this.dirtyRead || this.dirtyWrite) {
|
||||
if (this.dirtyRead) {
|
||||
this.dirtyRead = false
|
||||
try {
|
||||
await this._read()
|
||||
} catch (error) {
|
||||
// Half-written files are routine while the user is editing, hence
|
||||
// only a warning. With the file in an unknown state there is nothing
|
||||
// safe to write: unless another read is due, stop here and leave
|
||||
// `dirtyWrite` set for the next trigger.
|
||||
this.persistenceError = error
|
||||
this.ctx.logger.warn(error)
|
||||
if (this.dirtyRead) continue
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.dirtyWrite) {
|
||||
this.dirtyWrite = false
|
||||
try {
|
||||
await this._write()
|
||||
} catch (error) {
|
||||
this.persistenceError = error
|
||||
this.ctx.logger.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeFile(config: EntryOptions[]) {
|
||||
clearTimeout(this.writeTask)
|
||||
this.pendingWrite = config
|
||||
this.writeTask = setTimeout(() => {
|
||||
void this.flushWrite()
|
||||
}, 0)
|
||||
private async _read() {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(this.filename, 'utf8')
|
||||
} catch (error) {
|
||||
throw new ConfigFileError('read', this.filename, error)
|
||||
}
|
||||
if (content === this.cache?.content) return
|
||||
|
||||
let data: any
|
||||
try {
|
||||
data = this.type === 'application/yaml'
|
||||
? yaml.load(content, { schema })
|
||||
: JSON.parse(content)
|
||||
} catch (error) {
|
||||
throw new ConfigFileError('parse', this.filename, error)
|
||||
}
|
||||
// An empty or truncated file (routine mid-edit) parses to `undefined`, so
|
||||
// every non-array shape is rejected here as one "invalid file" signal.
|
||||
if (!Array.isArray(data)) {
|
||||
throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
|
||||
}
|
||||
|
||||
this._assignIds(data)
|
||||
|
||||
if (this.cache) {
|
||||
const index = new PatchIndex(this.config.patches ?? [])
|
||||
const conflicts = reconcile(this.journal, flatten(this.cache.data), flatten(data), (id, key) => index.fileOwned(id, key))
|
||||
for (const { id, reason } of conflicts) {
|
||||
this.ctx.logger.error('config conflict in %C: entry %C %s; file wins', this.filename, id, reason)
|
||||
}
|
||||
}
|
||||
|
||||
this.cache = { content, data }
|
||||
this._scheduleApply()
|
||||
// the file changed while tree-side changes were pending: write the merge
|
||||
if (this.journal.size) this.dirtyWrite = true
|
||||
}
|
||||
|
||||
private flushWrite(): Promise<void> {
|
||||
clearTimeout(this.writeTask)
|
||||
this.writeTask = undefined
|
||||
const config = this.pendingWrite
|
||||
this.pendingWrite = undefined
|
||||
if (config === undefined) return this.writeQueue
|
||||
const run = this.writeQueue.then(
|
||||
() => this._writeFile(config),
|
||||
() => this._writeFile(config),
|
||||
)
|
||||
this.writeQueue = run
|
||||
void run.catch((error) => {
|
||||
this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename)
|
||||
this.ctx.root.logger?.('loader').warn(error)
|
||||
private async _write() {
|
||||
if (!this.cache || !this.journal.size) return
|
||||
const current = await readFile(this.filename, 'utf8').catch((error) => {
|
||||
if (isENOENT(error)) return undefined
|
||||
throw error
|
||||
})
|
||||
return run
|
||||
if (current === undefined) {
|
||||
this.ctx.logger.warn('config file %C is missing, %d pending change(s) kept in memory', this.filename, this.journal.size)
|
||||
return
|
||||
}
|
||||
if (current !== this.cache.content) {
|
||||
// someone else wrote the file: merge first, then come back
|
||||
this.dirtyRead = true
|
||||
this.dirtyWrite = true
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot and swap: changes reported while we are writing belong to the
|
||||
// next round.
|
||||
const batch = this.journal
|
||||
this.journal = new Map()
|
||||
const restore = () => {
|
||||
for (const [id, record] of this.journal) merge(batch, id, record)
|
||||
this.journal = batch
|
||||
}
|
||||
|
||||
const data = structuredClone(this.cache.data)
|
||||
const patches = structuredClone(this.config.patches ?? [])
|
||||
if (routeJournal(batch, data, patches, this.warn)) {
|
||||
// Take the new patches now so that the tree scheduled below already
|
||||
// reflects them; the round trip through the parent only persists them.
|
||||
this.config = { ...this.config, patches }
|
||||
if (this.ctx.fiber.entry) {
|
||||
// Not awaited: the returned promise is the tree reconciliation, and
|
||||
// the drain loop must not block on fibers.
|
||||
try {
|
||||
Promise.resolve(this.ctx.fiber.update(this.config)).catch((error) => {
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
} catch (error) {
|
||||
this.ctx.logger.error(error)
|
||||
}
|
||||
} else {
|
||||
this.ctx.logger.warn('patches of %C changed at runtime but cannot be persisted', this.filename)
|
||||
}
|
||||
}
|
||||
|
||||
// nothing for the file itself (patch-only changes, or ids we assigned to
|
||||
// anonymous entries that are already part of the cache)
|
||||
if (deepEqual(data, this.cache.data)) {
|
||||
this._scheduleApply()
|
||||
return
|
||||
}
|
||||
const text = this.dump(data)
|
||||
|
||||
try {
|
||||
await access(this.filename, constants.W_OK)
|
||||
} catch {
|
||||
restore()
|
||||
this.ctx.logger.warn('config file %C is read-only, %d pending change(s) kept in memory', this.filename, this.journal.size)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this._writeText(text, this.cache.content)
|
||||
} catch (error) {
|
||||
restore()
|
||||
if (error instanceof StaleWriteError) {
|
||||
this.dirtyRead = true
|
||||
this.dirtyWrite = true
|
||||
return
|
||||
}
|
||||
this.ctx.logger.warn('failed to write config file %C, %d pending change(s) kept in memory', this.filename, this.journal.size)
|
||||
throw error
|
||||
}
|
||||
this.cache = { content: text, data }
|
||||
this._scheduleApply()
|
||||
}
|
||||
|
||||
/** Forward tree mutations to the debounced writer until journal adoption. */
|
||||
commit(_change: EntryChange) {
|
||||
this.write()
|
||||
/**
|
||||
* Write through a temp file and a rename, so that no reader ever sees a
|
||||
* truncated file. When `expected` is given, the rename (and every retry of
|
||||
* it) only goes ahead while the file still holds that content.
|
||||
*/
|
||||
private async _writeText(text: string, expected?: string) {
|
||||
const tmp = join(dirname(this.filename), `.${basename(this.filename)}.${process.pid}.${this._seq++}.tmp`)
|
||||
try {
|
||||
await writeFile(tmp, text)
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
if (expected !== undefined) {
|
||||
const current = await readFile(this.filename, 'utf8').catch((error) => {
|
||||
if (isENOENT(error)) return undefined
|
||||
throw error
|
||||
})
|
||||
if (current !== expected) throw new StaleWriteError()
|
||||
}
|
||||
try {
|
||||
await rename(tmp, this.filename)
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= RENAME_RETRIES || !isTransientRenameError(error)) throw error
|
||||
}
|
||||
await sleep(RENAME_BACKOFF * (attempt + 1))
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(tmp, { force: true }).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule a write of the current root entry data. */
|
||||
write() {
|
||||
this.context.emit('loader/config-update')
|
||||
return this.writeFile(this.root.data)
|
||||
private dump(data: EntryOptions[]) {
|
||||
return this.type === 'application/yaml'
|
||||
? yaml.dump(data, { schema })
|
||||
: JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries the file leaves anonymous get an id here, before the loader sees
|
||||
* them. One that looks exactly like an entry we named on the previous read
|
||||
* keeps that id, so editing a neighbor does not restart it; anything else
|
||||
* gets a fresh id. Assigned ids stay in memory until the next write.
|
||||
*/
|
||||
private _assignIds(data: EntryOptions[]) {
|
||||
const used = new Set<string>()
|
||||
const pending: { options: EntryOptions; parent: string | null }[] = []
|
||||
const walk = (entries: EntryOptions[], parent: string | null) => {
|
||||
for (const options of entries) {
|
||||
if (options.id) {
|
||||
used.add(options.id)
|
||||
} else {
|
||||
pending.push({ options, parent })
|
||||
}
|
||||
if (options.group && Array.isArray(options.config)) {
|
||||
walk(options.config, options.id ?? null)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(data, null)
|
||||
|
||||
const previous = this.anonymous
|
||||
this.anonymous = new Map()
|
||||
for (const { options, parent } of pending) {
|
||||
let id: string | undefined
|
||||
for (const [candidate, info] of previous) {
|
||||
if (info.parent !== parent || !deepEqual(info.options, options)) continue
|
||||
id = candidate
|
||||
previous.delete(candidate)
|
||||
break
|
||||
}
|
||||
if (!id) {
|
||||
do {
|
||||
id = Math.random().toString(16).slice(2, 10)
|
||||
} while (used.has(id) || this.store[id])
|
||||
}
|
||||
used.add(id)
|
||||
this.anonymous.set(id, { parent, options: structuredClone(options) })
|
||||
const rest = { ...options }
|
||||
for (const key of Object.keys(options)) delete options[key]
|
||||
Object.assign(options, { id }, rest)
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleApply() {
|
||||
if (this._disposed || !this.cache) return
|
||||
const tree = applyPatches(this.cache.data, this.config.patches, this.warn)
|
||||
this._pendingTree = applyJournal(tree, this.journal, undefined, this.warn)
|
||||
this._applyTask ??= (async () => {
|
||||
try {
|
||||
while (this._pendingTree) {
|
||||
const tree = this._pendingTree
|
||||
this._pendingTree = undefined
|
||||
await this.root.update(tree)
|
||||
}
|
||||
} finally {
|
||||
this._applyTask = undefined
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -64,8 +64,8 @@ Node module internals are classified by their available APIs, including the v1 A
|
||||
|
||||
## Update completion
|
||||
|
||||
Entry updates await `loader/patch-context`, the Fiber update waterfall and disabled-entry disposal. Combined context/config changes refresh required bindings before restarting. Group and Include updates expose completion of their child updates; file persistence still follows Include’s debounced writer.
|
||||
Entry updates await `loader/patch-context`, the Fiber update waterfall and disabled-entry disposal. Combined context/config changes refresh required bindings before restarting. Group and Include updates expose completion of their child updates; file persistence completes through Include’s `flush()`.
|
||||
|
||||
## Tree mutation reports
|
||||
|
||||
`EntryTree.commit(change)` receives each programmatic mutation synchronously after options and group membership change. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; the existing Include writer persists the current tree. Entry ids cannot contain `:`, which separates nested tree paths.
|
||||
`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree reports a removal to the source tree and a creation to the destination tree. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Entry ids cannot contain `:`, which separates nested tree paths.
|
||||
|
||||
Vendored
+16
-1
@@ -11,7 +11,13 @@ import { createResolve } from '../resolve.ts'
|
||||
* - `options` absent: the entry was removed from `group`.
|
||||
* - `legacy` absent: the entry was created in `group`.
|
||||
* - both present: the entry was updated and now lives in `group`; `from` is
|
||||
* the group it left if the update moved it.
|
||||
* the group it left if the update moved it within the same tree. A move to
|
||||
* another tree reports a removal to the source tree and a creation to the
|
||||
* destination tree.
|
||||
*
|
||||
* `id` is local to the receiving tree; `Entry.id` prefixes it with the parent
|
||||
* tree's path. Only `EntryTree` mutation methods and explicit Fiber saves
|
||||
* report changes; file-driven `EntryGroup` reconciliation does not.
|
||||
*/
|
||||
export interface EntryChange {
|
||||
id: string
|
||||
@@ -66,6 +72,12 @@ export abstract class EntryTree {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a random tree-local id when `options.id` is absent.
|
||||
* @param options - Entry options; a generated id is written to `options.id`.
|
||||
* @returns The existing or generated id.
|
||||
* @throws When an explicit id contains `EntryTree.sep`, which separates nested tree paths.
|
||||
*/
|
||||
ensureId(options: Partial<EntryOptions>) {
|
||||
if (!options.id) {
|
||||
do {
|
||||
@@ -132,6 +144,9 @@ export abstract class EntryTree {
|
||||
const task = entry.update(options, false, true)
|
||||
if (entry.parent.tree !== source.tree) {
|
||||
source.tree.commit({ id: legacy.id, group: source, legacy })
|
||||
// The destination tree has no previous row, so it receives a creation.
|
||||
entry.parent.tree.commit({ id: legacy.id, group: entry.parent, options: entry.options })
|
||||
return task
|
||||
}
|
||||
const from = entry.parent === source ? undefined : source
|
||||
entry.parent.tree.commit({ id: legacy.id, group: entry.parent, from, options: entry.options, legacy })
|
||||
|
||||
Reference in New Issue
Block a user