Uh oh!
There was an error while loading. Please reload this page.
feat(metadata): watch(_, since) replays from sys_metadata_history, and what a bare watch() owes is written down - #11018
Conversation
…ite down what a bare watch() owes
Invariant 6 (resumability) had one written half and one inherited half.
Face 1 — the written MUST. `SysMetadataRepository.watch()` read `since` only
as a drop filter on live events, so an event that had already committed was
unreachable however low `since` was set, even though the repository holds a
durable per-org `event_seq` log and already reads it org-wide in
`nextEventSeq()`. A numeric `since` now replays every logged event with
`seq > since` before any live event, through that same query and the row→event
mapping extracted out of `history()`. The live listener registers before the
durable read starts and a `delivered` set of `seq` closes the replay→live
seam, so an event committing during the read is delivered exactly once. A read
failure is raised to the consumer rather than downgraded to a silent live-only
tail.
Face 2 — the inherited half, now written. `repository.ts` spoke only of
`seq > since`; with no `since` there is no such set, so "no `since` replays
everything" lived only as `InMemoryRepository`'s implementation and the shared
contract suite silently leaned on it. Invariant 6 now states the floor: a bare
`watch(filter)` is owed live events only, an implementation MAY additionally
deliver what already committed, and a caller MUST NOT rely on it. The suite's
filter clause is rewritten to that floor — subscription first, writes after —
because its old shape asserted a replay the contract does not owe.
`declaredDivergences: { resumableWatch: '#10842' }` is deleted; the pin it
swapped in went red when replay landed, which is the mechanism working.
Fixes#10842
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HMCo-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 14376970b68791a27d8dc8d6670ceb913c1151a9 && git checkout 14376970b68791a27d8dc8d6670ceb913c1151a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45204a5e3e96efabd92a128f3d0028b2de99fd3a 75a71f7a22dbe297bca64645c85a515dae580e7f && git checkout -B drift-repro 45204a5e3e96efabd92a128f3d0028b2de99fd3a && git merge --no-ff 75a71f7a22dbe297bca64645c85a515dae580e7f
node scripts/docs-audit/affected-docs.mjs --json 45204a5e3e96efabd92a128f3d0028b2de99fd3a
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32562729326 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10842
Option B per the maintainer ruling (2026-08-22): implement face 1 only, and write down what a
watch()with nosinceowes. A (full replay matchingInMemoryRepository) and C (carving engine-backed repositories out of invariant 6) are not taken.What this buys, stated honestly
It makes the written MUST true. It fixes nothing a user experiences today — no production caller passes a numeric
since(enumeration below), so the face implemented here has no consumer but the contract suite itself. That is a defensible buy becauserunRepositoryContractTestsis re-exported through@objectstack/metadata-core/testing, a publishedexportssubpath: the suite is a shipped product, not an internal helper. Please do not read this as a user-facing fix.The four deliverables
1. Face 1 — a numeric
sincereplays from the durable log.SysMetadataRepository.watch()readsinceonly as a drop filter on live events, so an event that had already committed was unreachable however lowsincewas set — while the repository held a durable per-orgevent_seqlog and already read it org-wide innextEventSeq(). It now replays through that samefind('sys_metadata_history', { where: { organization_id } })query. The row-to-event mapping was extracted out ofhistory()into a sharedrowToEvent()rather than copied — two copies would be two answers to "what isactorwhenrecorded_byis null", and the #4556 answer is exactly the kind that drifts silently when duplicated. The org-wide replay stamps each row's own(type, name), whichhistory()does not need to do because it resolved a single ref first; there is a pin for that.The live listener registers synchronously, before the durable read is issued, so an event committing mid-read is buffered rather than dropped. It then appears in both the replay batch and the live queue, and a
deliveredset ofseqcollapses the pair. A failed durable read is raised to the consumer instead of degraded into a silent live-only tail — #4867's rule one seam over: a cursor we could not read is not a cursor we may invent.2. The contract-text amendment (
packages/metadata-core/src/repository.ts, invariant 6 and thewatch()doc). The sentence:3. The suite's face-2 clause rewritten.
watch filters by type and namewrote twice and then opened a watch, expecting the match back — an assertion of a replay the contract does not owe. It is nowwatch filters by type and name — over the live stream: subscription first, writes after. It asserts the floor every implementation owes, so it is unconditional across all three.4.
declaredDivergences: { resumableWatch: '#10842' }deleted. The pin atcontract-suite.ts:496went red the moment replay landed, which is the mechanism working — it is what told the call site to delete the line.FINDING — the ruling's suggested literal wording would break two of the three implementations
The ruling said the amendment was "most likely live events only". Written as a hard MUST NOT ("a
watch()with nosinceMUST NOT replay"), that sentence makes two of the three shipped implementations non-conforming:watch(filter)behaviourInMemoryRepository(packages/metadata-core/src/in-memory-repository.ts)FileSystemRepository(packages/metadata-fs/src/repository.ts)this.log.readAll()intoreplayat subscribe;createWatchIterablefilters it bysinceonly whensinceis a numberSysMetadataRepositoryForbidding it is a behaviour change to two implementations plus whatever leans on them (the
metadata-fstests passsince: 999precisely to suppress that replay, so it is known and load-bearing there), and that is a separate decision nobody has taken. Requiring it is option A, explicitly declined. So the landed sentence states the guarantee floor and denies reliance on anything above it — which is a strictly stronger statement than the silence it replaces, and the one wording under which the invariant table keeps having no per-implementation columns. Reported rather than quietly chosen, per the dispatch's compensating item 2. The invariant-6 block inrepository.tscarries this reasoning inline so the next reader is not left to rediscover it.Deviation, declared: where the "must fail if it replays without
since" control livesThe dispatch asked that face 2's control fail if the repository starts replaying without
since. That cannot be a shared-suite clause: under the sentence above, replaying is a MAY, so such a clause would red forInMemoryRepositoryandFileSystemRepository— a per-implementation column in the one table whose value is having none (the exact reason C was declined). The control therefore lives beside the implementation, inpackages/metadata-protocol/src/sys-metadata-repository.contract.test.ts, underSysMetadataRepository — invariant 6, both halves (#10842). It is falsified below, and the falsification also demonstrates the shared suite's blindness to that mutation, which is why the split is necessary rather than convenient.Evidence
Head
75a71f7a22. Every gatenode scripts/pm/dispatch-gates.mjs(no path arguments) named for this change set was run at that commit; exits captured before any pipe.Face 1's real defect control — red pre-fix, green post-fix. With only deliverable 4 applied (declaration deleted, no implementation change),
packages/metadata-protocol,vitest run src/sys-metadata-repository.contract.test.ts:Post-fix, same command:
Tests 42 passed (42), with both clauses green by name.All three implementations against the amended table —
@objectstack/metadata-corewas rebuilt first, becausemetadata-protocolandmetadata-fsresolve@objectstack/metadata-core/testingthroughexportstodist/with no vitest alias, so an unbuilt suite change is invisible to them:InMemoryRepository(via../src/contract-suite.js, source): 34 passedFileSystemRepository(viadist): 31 passedSysMetadataRepository(viadist): 42 passedFalsifications (each: mutate, run, restore, prove
git hash-objectequalsgit rev-parse HEAD:PATH,git diff --exit-code0, porcelain empty):watch()replays even with nosince(option A by accident)expected [ Array(2) ] to deeply equal []on all three subscriber shapes, plus a fourth. The shared suite stayed green — the evidence for the split aboveexpected [ 2, 3, 3 ] to deeply equal [ 2, 3 ]namefilter inmatchesFilterexpected [ 'a', 'b' ] to deeply equal [ 'a' ]— the rewritten clause is not vacuousThe accidental-option-A check, as a named result. After this change, both production subscribers replay nothing at attach time:
MetadataManager.startRepositoryWatch()(repo.watch({}),packages/metadata/src/metadata-manager.ts:2807) andMetadataCache.start()(repo.watch(this.watchFilter),packages/metadata-core/src/cache.ts:113, default filter{}). Pinned as threeit.eachcases naming those call shapes, and falsified above.Caller enumeration, re-derived. Production callers of
MetadataRepository.watch():metadata-manager.ts:2807({}, nosince),cache.ts:113(this.watchFilter, nosince), andlayered-repository.ts:178(layer.repo.watch(filter, since)— pure forward of whatever it was given). No production caller passes a numericsince; the card's assertion holds. Non-repository false positives:chokidar.watch(metadata-fs/src/repository.ts:534,cli/src/commands/dev.ts:546), RedisWATCH(service-cluster-redis/src/kv.ts), andmd.watch('*', cb)inplugin-security/src/security-plugin.ts:903, which isMetadataManager's callback API, a different method entirely.Other suites:
metadata-core165,metadata-fs56,metadata-protocol1831 (+10 skipped),metadata615, and theobjectqlwatch()consumers (sys-metadata-repository.test.ts,layered-overlay-integration.test.ts) 42 — all passing.Changeset
minoron@objectstack/metadata-protocoland@objectstack/metadata-core. Not a pure bug fix in the no-changeset sense:watch(filter, since)changes observable behaviour for any caller passing a number, and the shared suite's filter clause changes shape for anyone runningrunRepositoryContractTestsagainst their own implementation. The changeset body carries that FROM/TO so an upgrading author can act on it fromCHANGELOG.md.Generated by Claude Code