Commit Graph
4 Commits
Author SHA1 Message Date
Yichen Jiang bdc6d95d56 fix(settings): close third-review watcher-lifecycle and write-boundary gaps
Review round three found four seam defects:

- A watch() disposer only removed the observer from the set; an invocation
  already chained onto the watcher tail still ran after disposal. Watchers
  now carry an active flag checked when a queued invocation would start,
  and the service dispose drain awaits started invocations (pendingTails)
  beside the write queues, so disposal is quiescent.
- The settings/updated manual fan-out caught only synchronous throws; an
  async listener rejection escaped as an unhandled rejection. Thenable
  returns are now contained through the shared listener diagnostic, and
  the event contract documents that the INVARIANT rethrow serves
  synchronous listeners only.
- structuredClone admitted Dates, Maps, BigInts, and cycles that YAML/JSON
  storage silently distorts on reload (a Date lands as a timestamp string,
  a Map as a plain map, a BigInt as a number). The write snapshot is now a
  single-pass cloneJsonShaped walk that rejects non-JSON values with their
  path before anything persists.
- mergeLayers' per-entry undefined guard became dead code once the clone
  strips undefined entries at the boundary; removed, with the sparse-patch
  contract restated at its enforcement point.
2026-07-30 13:29:52 +08:00
Yichen Jiang 1010291fe6 fix(settings): close cross-namespace, dispatch, and lifecycle races from second review
Confirmed and fixed, each with a regression test that failed first:

- Concurrent writes to different namespaces lost whole sections on disk
  (each persist rendered the full document from a stale text): the local
  provider serializes render->write->rename->text-commit on one internal
  persist chain shared by every namespace queue.
- One throwing settings/updated listener starved the rest (cordis emit
  stops at the first throw): commit fans out per listener via
  events.dispatch, contains individual failures, and rethrows the first
  INVARIANT-coded error only after every listener ran.
- Write queues ignored fiber/service lifecycle: the base init now
  registers a teardown that refuses new writes and drains queued chains;
  queued tasks re-verify service liveness and namespace ownership before
  running and again before committing, so a registrant disposed
  mid-flight is never notified and a disposed service never commits.
- Async watcher invocations could interleave (a slow stale call applied
  last): each watcher carries a serialized invocation chain — one call
  at a time, in commit order; JSDoc/doc pages state the async timing.
- update/replace borrowed the caller's object until the queued task ran:
  inputs are structured-clone snapshotted at call time; non-cloneable
  plain objects reject with a typed error.
- Composition guard now proves the documented fallback: the consumer
  uses the optional scoped-inject shape and boots both with the settings
  entry (hot publish) and without it (entry-config resolution, no scope).
- core-data-structures index: settings.md row added to the sub-page
  table in core.md/core.zh.md.

Both packages hold per-file 100% coverage across repeated runs.
2026-07-29 10:19:33 +08:00
Yichen Jiang f44b4db1f2 fix(settings): harden seam and provider per review findings
Confirmed and fixed, each with a regression test that failed first:

- Concurrent update() lost patches (merge over one stale snapshot):
  per-namespace serialized write queues; a failed write cannot poison
  the queue for later writers.
- Fixed-name .tmp write followed planted symlinks and kept stale modes:
  random-suffix sibling, exclusive-create (wx), 0600, cleanup on
  failure, then rename.
- A throwing settings/updated listener escaped commit and permanently
  wedged the provider reload chain (rejected refreshTask): commit now
  contains listener failures (INVARIANT-coded errors still propagate),
  async watcher rejections are adopted and contained
  (watch callbacks are officially void | Promise<void>), and the
  provider chains refreshes on a settled tail with an error log.
- No way to remove a user override: scope/service replace(section)
  sets the user section wholesale; replace({}) re-inherits base and
  schema defaults.
- The three-primitive provider contract did not hold (base never
  called load()): the base Service.init loads and publishes once;
  settings-local delegates via yield* super[Service.init]().
- Dispose did not quiesce: teardown flags closed, closes the watcher,
  then awaits queued/in-flight reloads; closed is re-checked across
  await points.
- Invariant now checks the authoritative relation with the seam's own
  deepEqualJson: emitted next must equal settings.get(ns), and
  next/prev must differ structurally (cosmokit dependency dropped).
- New docs/core-data-structures/settings.{md,zh.md} with type-equiv
  blocks + manifest entries; catalog types moved from exemptions to
  LINK_MAP; website page registered.

Both packages stay at per-file 100% coverage.
2026-07-29 10:19:32 +08:00
Yichen Jiang ec0786e099 feat(settings): add user-settings seam (ctx.settings) + file provider
Two-package capability family mirroring session-persistence/:

- dsh-settings: abstract Settings service — namespace registry with
  caller-fiber effect registrations, layered resolution (schema defaults
  < composition base < user document), schemastery validation,
  per-namespace deep-equal commit detection, and the settings/updated
  event. Boot/registration validation fails loud; provider publishes
  keep last-good per namespace.
- dsh-settings-local: settings.yaml/.json provider — resolveSpec
  defaulting to $DSH_HOME/settings.yaml, chokidar hot reload,
  content-equality self-write suppression, atomic 0600 tmp+rename
  writes, comment-preserving YAML namespace patching.

Consumers register inside ctx.inject(['settings'], …), so every
composition works unchanged without a mounted provider. Real Loader +
Include composition test proves cordis.yml boot and external-edit hot
propagation; HMR disposal test proves registry cleanup. Both packages
hold per-file 100% coverage.

Doc budgets rise 1705→1710 (AGENTS.md) and 835→845 (packages/README.md):
one structural line per file for the new package group.

Agent Note: .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md
2026-07-29 10:19:32 +08:00