mirror of
https://github.com/gastownhall/beads.git
synced 2026-09-14 20:17:24 +08:00
34b9a132f8
* feat(storage): engine reads — EventsSince, GetDependentRecords, EventClaimed
`bd activity`/`bd show` render event history, and the library's own callers
need a durable, resumable feed of what changed — but the storage surface had
only the time-granular GetAllEventsSince, no keyset cursor, and no raw
target-keyed dependents read. Add both as additive extension interfaces plus
backend methods; no wire, schema, CLI, or existing-signature changes. Each new
read delegates its SQL to the shared issueops helpers the backends already use.
- Extract the raw "claimed" event literal into a typed EventClaimed constant
(types) and use it at the single write site (issueops/claim.go). The on-disk
value is byte-identical.
- EventsSince(cursor{created_at,id}, limit) — a durable-only keyset read over
the events table, for a change feed that pages forward without dropping or
duplicating same-second ties. New EventQueryStore extension interface +
EventCursor, composed into DoltStorage and implemented on the Dolt backends.
- GetDependentRecords(targetID, depType, limit, afterID) on
DependencyQueryStore — a raw target-keyed dependents read (the inbound edges)
that returns edges from dangling/cross-project/wisp sources rather than
dropping them, so `bd dep list`-style dependents views see the whole set.
Real-Dolt tests cover direction correctness, type filter, keyset paging,
same-second id tie-break, cursor exclusivity, durable-only scope, and the
claimed-constant write path.
(cherry picked from commit 35ae4d186047d99bef6d492132f5767ef2db5d7d)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(storage): make EventsSince sargable + add a per-bead scope
The EventsSince cursor predicate `(created_at > ?) OR (created_at = ? AND id > ?)`
full-scans on Dolt (EXPLAIN: Filter over a bare Table + TopN). Rewrite it to the
logically-equivalent, SARGABLE form
created_at >= ? AND ((created_at > ?) OR (id > ?))
The redundant `created_at >= ?` lower bound flips the plan to
IndexedTableAccess(events) on [events.created_at] (verified by a new
EXPLAIN FORMAT=TREE regression test). The composite (created_at, id) row-value
form is deliberately NOT used: the events table has only single-column
idx_events_created_at (no composite index) on every backend, so the >= lower
bound is the seeking form; the shared issueops query serves each backend.
Also give EventsSince an optional issueID filter ("" = all) implemented off the
same keyset with `issue_id = ?`, so `bd show`'s per-bead event history is one
scoped read of the same primitive rather than a second one. The signature is
changed while unmerged across the interface + dolt/embeddeddolt backends.
Coverage/docs: dolt suite gains issue-filter, limit-clamp (default 100 / cap
500), and the EXPLAIN sargability guard; embeddeddolt gains a mirrored feed
test; the interface doc names idx_events_created_at and states the
commit-visibility-lag caveat (a resuming consumer overlaps a ~45s slack window
and de-duplicates by id).
(cherry picked from commit 3b7903290fad76e15ece171dcf6bdfd5f0f584b8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(storage): single-source the EventsSince sargability guard from production SQL
The EXPLAIN sargability guard asserted a hand-copied literal of the EventsSince
query, so it could pass while the production predicate silently drifted.
Single-source it: export issueops.EventsSinceQuery(issueID, limit) — the exact
SQL EventsSinceInTx runs — and have the durable-events plan guard EXPLAIN that
string (placeholders literalized) instead of a copy. A change to the SARGABLE
predicate now breaks the guard.
Adds a literalizeParams test helper that binds ? placeholders to literals in
order and panics on arity drift, so a drifted placeholder count fails loudly
rather than EXPLAINing malformed SQL.
(cherry picked from commit dda19e737ac17ae4387c6abc4a1acca287b85494)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(storage): dependents reads — total id cursor, sargable target, + CountDependentRecords
Three coupled fixes to the target-keyed dependents read behind `bd dep list`'s
dependents views and the library's own callers:
TOTAL KEYSET. The old cursor paged on source issue_id, which is not a total key
for a fixed target: a source can appear across the durable and wisp dependency
tables, so paging on it could drop or duplicate rows (the Type tie-break was
dead code and the "issue_id is unique" comment was false). Cursor instead on the
dependency row's own primary id (depid.New(issue_id, target), a UUIDv5 that is
stable and globally unique across both tables): ORDER BY id ASC, afterID = last
row's id. types.Dependency gains an additive ID field, scanned by a dedicated
scanDependentRow (the shared scanDependencyRow is left untouched so no
source-keyed read has to select id). The two per-table pages merge by id and
truncate — total, no drop, no dup, proven by a page-size-1 walk across a
boundary that spans both tables (durable + wisp sources).
SARGABLE TARGET. Replace COALESCE(target...)=? (an expression no index can
match) with an explicit per-column OR, depTargetEqualsOr. On an index-merging
planner it plans as a BitmapOr index-merge over the three idx_dep_*_target
indexes where COALESCE was a full scan; on Dolt it is at worst no worse than
COALESCE, and the type-filtered path seeks the (type, target) composite on both.
CountDependentRecords(ctx, targetID, depType): the same sargable predicate,
COUNT only, summed across both tables — callers need a true total membership
count without paging to exhaustion. Implemented once in issueops and wired onto
the DependencyQueryStore interface + the dolt/embeddeddolt backends.
Coverage: dolt + embeddeddolt tests for both reads (two-table span, type filter,
keyset boundary, limit clamp default 100). Method docs name the cursor key and
the two-table raw-read / policy-at-hydration contract.
(cherry picked from commit 16db364d94f8525b9800483dc5ddcdccf94593f8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(storage): de-dup cross-table depid collisions in dependents reads
depid.New keys a dependency row's id on (issue_id, target) and omits the table,
so the SAME edge present in BOTH dependencies and wisp_dependencies — a wisp
promoted to durable, or two Dolt clones merged — carries one id in both tables.
GetDependentRecordsInTx appended both per-table pages and sorted by id, so a
colliding edge produced a duplicate id in a merged page (and, at a page
boundary, a dropped row on the next `id > afterID`). CountDependentRecordsInTx
summed two raw COUNT(*)s, over-counting the collision beyond the distinct keyset
row count.
De-dup the paged read by id, iterating durable first and keeping the durable
(authoritative, non-ephemeral) row. Make the count agree with that de-dup:
durable rows plus wisp rows whose depid is not already a durable row for the same
target/type (an uncorrelated, bounded NOT IN over the sargable per-column OR
target predicate), so the total is distinct-by-id.
Adds a dolt regression test that seeds the same edge in both tables (FK checks
relaxed to reproduce the post-merge state) and asserts one row per id in pages,
Count == distinct total (3, not the sum 4), and stable keyset paging across the
collision. Fails before this change (id appears twice), passes after.
(cherry picked from commit c6825f8513cd38967fb73856253bd99a5eb65fc2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(storage): batched target-keyed dependents read (GetDependentRecordsForIssues)
Adds GetDependentRecordsForIssues(ctx, targetIDs) → map[targetID][]*Dependency:
for a SET of target ids in ONE batched read, each id's INCOMING dependency rows
(its dependents), spanning BOTH the durable and wisp dependency tables, ALL dep
types (the caller filters), de-duped by row id. It is the target-keyed mirror of
the source-keyed GetDependencyRecordsForIssues — the whole-page read that lets
`bd dep list`-style dependents views and the library's own callers render every
id's inbound blocking edges without a per-id fan-out, and unlike
GetBlockingInfoForIssues's blocksMap it keeps the full type set (waits-for /
conditional-blocks are not dropped) and each row's real dep_type.
Cross-table de-dup matches GetDependentRecordsInTx: a wisp promoted to durable
carries one depid in both tables, so the durable table is scanned first and a
repeat id is skipped. The target is matched by the coalesced target expression
(depTargetIn), the same predicate the batched blocks/counts reads use.
Wired through issueops + the DependencyQueryStore interface + the dolt /
embeddeddolt backends. (The public-root re-export via beads.DependentQuerier
lands with the narrow public query-interface work.)
Tests: dolt + embeddeddolt batched-read regressions (multi-target keying, both
tables, full type set incl conditional-blocks/waits-for, decoy exclusion, absent
leaf, empty input).
(cherry picked from commit 1f69cedf282471d474ac5b1be633c7a9b18990c8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(storage): batch transitive is_blocked read on DependencyQueryStore
`bd`'s discovery/ready-list views and the library's own callers need the
denormalized transitive is_blocked value for a PAGE of up to ~200 issues; the
surface only had the single IsBlocked(ctx, id), so a page cost N per-row calls.
Add IsBlockedBatch(ctx, ids) (map[string]bool) to the DependencyQueryStore
interface and implement it once in issueops.IsBlockedBatchInTx: a batched
SELECT id, is_blocked FROM {issues,wisps} WHERE id IN (...) at queryBatchSize=200
— the same denormalized column IsBlocked reads, NOT a recompute — so it returns
the identical transitive value per id, reflecting inherited/ancestor blockedness
(a child of a blocked parent is blocked with no direct blocking edge). ids
present in neither table are absent from the map; cross-table dups prefer the
wisps row, matching loadStatusByIDInTx and the search wisp-merge.
Wired onto the dolt and embeddeddolt backends via the same pattern as
CountDependentRecords (each delegates through its read tx).
Test: a dolt parity suite seeding a direct blocker, an inherited parent-child
block (transitive is_blocked with an EMPTY direct-blocker set), and an unblocked
control, asserting IsBlockedBatch agrees with per-row IsBlocked for every id and
that a missing id is absent from the map.
(cherry picked from commit 1cd441231e2c5a2b45064db01a03bb06de9009b9)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(issueops): IsBlockedBatch issues-wins on cross-table id collision, matching IsBlocked
IsBlockedInTx (per-row) scans issues→wisps and breaks on the first table that
has the id, so ISSUES wins the shared denormalized is_blocked field on a
cross-table (issues vs wisps) id collision. IsBlockedBatchInTx preferred the
wisps row, so the two reads disagreed on the same stored flag for an id present
in BOTH tables.
Make the batch keep the first-seen (issues) value and skip any later (wisps)
duplicate, mirroring the single read's precedence exactly. Both the dolt and
embeddeddolt backends delegate to this shared function, so they stay consistent.
Add a dolt regression seeding the same id in issues (is_blocked=1) and wisps
(is_blocked=0) and asserting IsBlocked(id) == IsBlockedBatch([id])[id] == the
issues value. Verified it fails before the fix (true vs false).
(cherry picked from commit 7dbd4f849d589acb15f8168532fcf8874aa87c99)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(embeddeddolt): IsBlockedBatch parity regression on the embedded backend
Mirror the dolt IsBlockedBatch coverage on the embedded Dolt backend: assert the
batched transitive is_blocked read agrees with per-row IsBlocked for every id and
reflects an inherited parent-child block (is_blocked true with an empty
direct-blocker set). Complements the already-present embedded keyset regression.
(cherry picked from commit 414e7eaf8de43eb1786088c2851c18763f6724ba)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(beads): re-export claim surface, error taxonomy, and query interfaces on public root
Public root-package surface for the claim/error/query reads consumers reach by
type-assertion. Pure re-export + a string-coupled conflict shim; no engine
behavior changes.
- IssueClaimer interface (ClaimIssue/ClaimReadyIssue, which live on the
storage.BulkIssueStore extension, not the base Storage) + AsIssueClaimer
type-assertion helper. Callers assert once at startup and fail loud, per the
decorator note.
- Re-export ErrCircuitOpen (storage/dolt) as an aliased root sentinel so
errors.Is works across the boundary (the claim sentinels ErrAlreadyClaimed /
ErrNotClaimable are already re-exported with the other sentinels); a
wrap-preservation test pins this against errors wrapped the way the engine
wraps them.
- Typed conflict detail: the conditional-UPDATE claim path returns no typed
result on conflict — the current assignee/status remains embedded in the
error string. Rather than change that internal signature, expose
ClaimConflict + ParseClaimConflict (with its own test) to recover it.
- Re-export DependencyQueryStore, EventQueryStore, EventCursor aliases and the
EventClaimed constant so consumers reach the new reads by type-assertion.
(cherry picked from commit 70f443ad941117a74f3143f8570f91a0f721ece4)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(beads): derive claim-conflict markers from a storage source of truth
ParseClaimConflict hardcoded the message literals "issue already claimed by "
and "issue not claimable: status ", coupling the root parser to the engine's
fmt.Errorf format three layers away with nothing to catch drift.
- Export the two format fragments next to the sentinels: storage.ClaimedByFragment
(" by ") and storage.NotClaimableStatusFragment (": status "). issueops/claim.go
now wraps with "%w%s%s" using them (on-disk/message bytes unchanged), and
ParseClaimConflict reconstructs its markers as ErrAlreadyClaimed.Error()+fragment
rather than hardcoding — producer and parser can no longer drift independently.
- Harden tailAfter: recover the assignee/status only as the trailing run; if the
tail still contains a known marker (an appended wrap corrupted it) return "".
Documents the repo convention that claim-error wraps PREPEND context.
- Producer-tied tripwires: a dolt-suite round-trip (real ClaimIssue → both
conflict branches, recovered via the storage fragments) and a root external
round-trip (real Dolt store via beads.Open → AsIssueClaimer → ParseClaimConflict
recovers assignee/status). The latter also exercises AsIssueClaimer against a
live Storage.
(cherry picked from commit aced93f3d93c3fb779702df0549fab58369af997)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(beads): bound the claim-conflict token so an appended wrap can't leak garbage
tailAfter took the entire trailing run after the fragment, guarding only against
a further embedded full marker. A plain appended wrap — fmt.Errorf("%w (ctx)",
claimErr) — sailed past that guard and returned "alice (ctx)" as the assignee.
Bound the token to a single whitespace-free run: a status never contains
whitespace and an assignee is a whitespace-free actor id by convention, so if the
tail carries trailing content (whitespace or an appended "(...)" wrap) we return
"" — the documented best-effort empty-field outcome, with ParseClaimConflict
still reporting ok=true on the errors.Is match. A prepended wrap (the repo
convention) and a clean conflict are unaffected.
Tests: a direct tailAfter table (clean, prepended, appended-paren, appended-space,
absent, empty) and a ParseClaimConflict appended-wrap case asserting empty field
with ok=true.
(cherry picked from commit 181f47c7b7b4bb397c5f16992d1e33b75982eb04)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(beads): narrow public query interfaces + claim-surface drift guards
Public-surface hardening for the library's consumers, done while unmerged
(cheap now, expensive later).
Stop aliasing whole internal engine interfaces on the root. Replace the
`DependencyQueryStore = storage.DependencyQueryStore` / `EventQueryStore =
storage.EventQueryStore` aliases with NARROW, hand-declared root interfaces that
expose exactly what consumers use:
- EventQuerier { EventsSince(...) }
- DependentQuerier { GetDependentRecords(...); CountDependentRecords(...) }
plus AsEventQuerier / AsDependentQuerier helpers mirroring AsIssueClaimer. Every
As* helper now first tries the direct assertion, then storage.UnwrapStore, so a
HookFiringStore-decorated store keeps its capability (AsIssueClaimer gains the
same unwrap; previously it would return false for a decorated store).
Drift guards: compile-time assertions tie each narrow interface to the full
engine interface storage.DoltStorage (in beads.go) AND to both concrete stores —
*dolt.DoltStore and *embeddeddolt.EmbeddedDoltStore (in tests) — so a signature
change to the claim/event/dependents surface breaks the build instead of
silently making As* return false. A live-Dolt test exercises AsEventQuerier /
AsDependentQuerier and their methods end to end.
(cherry picked from commit ddf7b2ab8a2fa41e36f13ccf701983d0bcd01faa)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(beads): drop the dead UnwrapStore fallback from the As* accessors
The storage.UnwrapStore fallback in AsIssueClaimer/AsEventQuerier/
AsDependentQuerier was provably unreachable. ClaimIssue/ClaimReadyIssue,
EventsSince, and the dependents reads all live on the engine interface
storage.DoltStorage (via BulkIssueStore / EventQueryStore /
DependencyQueryStore), and the compile-time drift guards prove
storage.DoltStorage satisfies each narrow surface. So whenever the fallback's
`s.(storage.DoltStorage)` succeeds, `s` already satisfies the narrow surface and
the leading direct assertion has already returned — branch 2 can only run when
branch 1 failed, and branch 1 failing implies s is not a DoltStorage, so branch 2
fails too. The only decorator, HookFiringStore, embeds storage.DoltStorage and
overrides none of these methods, so it forwards them by promotion and resolves on
the direct assertion.
Reduce the three accessors to a single direct type-assertion and document the
decorator contract (a decorator MUST embed storage.DoltStorage — unlike the
cmd/bd optional interfaces such as StoreLocator/BackupStore, which are not part
of DoltStorage and so genuinely need UnwrapStore, which is unchanged and still
used there). Adds a test that a HookFiringStore-decorated live store resolves
through all three accessors — the guard that would catch a future decorator that
stops forwarding.
(cherry picked from commit 0d9d0500edd3c165f101bbb590dcea7147f3530b)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(beads): re-export the batch transitive-blocked surface on the public root
Add BlockedQuerier (IsBlocked + IsBlockedBatch) and AsBlockedQuerier to the public
beads package, following the established narrow-interface + As* accessor style
(EventQuerier/DependentQuerier): a hand-declared root interface exposing only the
transitive-blocked reads consumers use, reachable via a single direct type
assertion, with a compile-time drift guard (var _ BlockedQuerier =
(storage.DoltStorage)(nil)) so a signature change on the engine breaks the build
here instead of silently making the accessor return false.
The (created_at, id) keyset needs no new re-export: it is additive fields on the
already-re-exported beads.IssueFilter, honored by the already-exported
Storage.SearchIssues / SearchIssuesWithCounts.
query_interfaces tests gain the concrete-store conformance assertions (server +
embedded Dolt), a live-Dolt exercise of AsBlockedQuerier proving IsBlockedBatch
agrees with per-row IsBlocked through the public surface, and the decorator-
contract check that AsBlockedQuerier resolves through a HookFiringStore.
(cherry picked from commit e8b87b6acf601bc2b323d92fbe863c4be7a99386)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(beads): re-export GetDependentRecordsForIssues on the public DependentQuerier
Surface the batched target-keyed dependents read (GetDependentRecordsForIssues,
landed at the storage layer earlier in the dependents-reads work) on the public
root DependentQuerier interface, so a consumer reaching the dependents surface
via AsDependentQuerier can fetch a whole page of inbound edges for a SET of
target ids in one call — the batched mirror of GetDependencyRecordsForIssues —
without a per-id fan-out. A live-Dolt query_interfaces assertion exercises it
through the public surface.
(cherry picked from commit 1f69cedf282471d474ac5b1be633c7a9b18990c8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(storage): transactional snapshot reads on storage.Transaction
Composite views (bd show-style assemblies of counts, relations, comments, and
history) previously issued each read as its own store call with no shared
snapshot, so related numbers could disagree. Widen storage.Transaction with
the read subset those views need — CountIssuesByGroup, GetIssueCommentsPage,
GetDependentRecords/GetDependentRecordsForIssues/CountDependentRecords,
IsBlocked/IsBlockedBatch, EventsSince — so a caller can assemble the whole
view inside one transaction. Pure surface addition: every method delegates to
the existing InTx implementation; no new SQL, no semantics changes.
On the server backend the transaction spans two sessions (durable + clone-
local). Single-tier reads route by wispness and see this transaction's own
uncommitted writes on both tiers (comments page, blocked checks — including
mixed batches via tier partitioning). Reads that union both tables in one
query (dependents family, grouped counts) run on the durable session and see
uncommitted durable writes plus committed clone-local state; that limitation
is documented per method on the interface and asserted per backend in the
tests. The embedded backend is single-handle and fully read-your-writes.
Conformance adds a snapshot test that mutates inside the transaction and
asserts exact deltas across all the new reads (a non-transactional delegation
fails it), a read-your-writes test covering every method, and wisp-bearing
fixtures; the server backend adds a clone-local-tier read-your-writes test
pinning both the routed reads and the documented spanning-read behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issueops): drop depTargetEqualsOr's dead alias parameter
Every caller on this branch passes "" — the aliased form's only consumer
lived in a backend package that does not exist here — so golangci-lint's
unparam check rejects the parameter. Inline the bare-column form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): wrap ErrAlreadyClaimed for open foreign-assignee; split tx conformance tests
Maintainer review fixes for PR #4926 (iteration 1).
Contract (major): issueops.ClaimIssueInTx's open-but-assigned refusal branch
returned a plain error, so the public IssueClaimer contract (wrapped conflict
sentinels, classified by errors.Is / ParseClaimConflict) was false for the
common "open issue pre-assigned to another actor" case. Wrap
storage.ErrAlreadyClaimed while preserving the holder-focused wording, matching
the already-correct domain-stack twin. Adds a conformance test
(ClaimOpenForeignAssignee) and a ParseClaimConflict subtest for the wrapped
open-assigned form.
Complexity (major): split testTransactionSnapshotReads and
testTransactionReadYourWrites into named read + assertion helpers
(readSnapView/assertSnapBaseline/assertSnapDelta and
seedReadYourWritesGraph/assertInTxDependentReads/assertInTxCountsAndEvents) with
equivalent coverage, dropping the worst changed function out of the major
cyclomatic/cognitive band (39/73 -> 11/17 and 23/38 -> 4/5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(issueops): extract per-table read to cut IsBlockedBatchInTx complexity
Review fix for #4926. IsBlockedBatchInTx inlined the whole two-table batched
scan (gocognit 29, gocyclo 12), which the quality scorecard flagged as a major
readability finding. Extract the per-table batched read into
readIsBlockedIntoFromTable, mirroring the sibling
GetDependentRecordsForIssuesInTx / getDependentRecordsIntoFromTable split, so the
outer function only dispatches the issues+wisps read and nesting resets at the
function boundary.
Behavior-preserving: the issues-win cross-table de-dup and the optional-wisps
table-not-exist skip are unchanged -- same helper-wraps / outer-classifies idiom
as the sibling, and isTableNotExistError unwraps the %w wrap. The embedded
TestIsBlockedBatchEmbedded parity test still passes against a real Dolt backend.
gocognit 29 -> helper 15; gocyclo 12 -> 6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Test User <test@test.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: CI Bot <ci@beads.test>
150 lines
5.3 KiB
Go
150 lines
5.3 KiB
Go
package beads_test
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/storage/dolt"
|
|
)
|
|
|
|
// Compile-time proof that the concrete Dolt store satisfies each narrow public
|
|
// interface (the embedded Dolt store is asserted in query_interfaces_cgo_test.go).
|
|
// These pin the interfaces to a real implementation, complementing the
|
|
// engine-interface guards in beads.go.
|
|
var (
|
|
_ beads.IssueClaimer = (*dolt.DoltStore)(nil)
|
|
_ beads.EventQuerier = (*dolt.DoltStore)(nil)
|
|
_ beads.DependentQuerier = (*dolt.DoltStore)(nil)
|
|
_ beads.BlockedQuerier = (*dolt.DoltStore)(nil)
|
|
)
|
|
|
|
// TestQueryInterfacesAgainstRealDolt exercises AsEventQuerier / AsDependentQuerier
|
|
// (and their methods) against a live Dolt Storage through the public surface,
|
|
// the runtime complement to the compile-time guards.
|
|
func TestQueryInterfacesAgainstRealDolt(t *testing.T) {
|
|
skipIfNoDoltServer(t)
|
|
|
|
ctx := context.Background()
|
|
store, err := beads.Open(ctx, filepath.Join(t.TempDir(), "qi-dolt"))
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer store.Close()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "qi"); err != nil {
|
|
t.Fatalf("SetConfig: %v", err)
|
|
}
|
|
|
|
mk := func(id string) {
|
|
iss := &beads.Issue{ID: id, Title: id, Status: beads.StatusOpen, Priority: 2, IssueType: beads.TypeTask}
|
|
if err := store.CreateIssue(ctx, iss, "tester"); err != nil {
|
|
t.Fatalf("CreateIssue %s: %v", id, err)
|
|
}
|
|
}
|
|
mk("qi-target")
|
|
mk("qi-src")
|
|
if err := store.AddDependency(ctx, &beads.Dependency{IssueID: "qi-src", DependsOnID: "qi-target", Type: beads.DepBlocks}, "tester"); err != nil {
|
|
t.Fatalf("AddDependency: %v", err)
|
|
}
|
|
|
|
dq, ok := beads.AsDependentQuerier(store)
|
|
if !ok {
|
|
t.Fatalf("AsDependentQuerier returned ok=false for a live Dolt Storage")
|
|
}
|
|
deps, err := dq.GetDependentRecords(ctx, "qi-target", "", 100, "")
|
|
if err != nil {
|
|
t.Fatalf("GetDependentRecords: %v", err)
|
|
}
|
|
if len(deps) != 1 || deps[0].IssueID != "qi-src" {
|
|
t.Fatalf("GetDependentRecords = %v, want [qi-src]", deps)
|
|
}
|
|
if n, err := dq.CountDependentRecords(ctx, "qi-target", ""); err != nil {
|
|
t.Fatalf("CountDependentRecords: %v", err)
|
|
} else if n != 1 {
|
|
t.Fatalf("CountDependentRecords = %d, want 1", n)
|
|
}
|
|
byTarget, err := dq.GetDependentRecordsForIssues(ctx, []string{"qi-target"})
|
|
if err != nil {
|
|
t.Fatalf("GetDependentRecordsForIssues: %v", err)
|
|
}
|
|
if got := byTarget["qi-target"]; len(got) != 1 || got[0].IssueID != "qi-src" || got[0].DependsOnID != "qi-target" {
|
|
t.Fatalf("GetDependentRecordsForIssues[qi-target] = %v, want one row {src=qi-src, target=qi-target}", got)
|
|
}
|
|
|
|
eq, ok := beads.AsEventQuerier(store)
|
|
if !ok {
|
|
t.Fatalf("AsEventQuerier returned ok=false for a live Dolt Storage")
|
|
}
|
|
evs, err := eq.EventsSince(ctx, beads.EventCursor{}, "", 100)
|
|
if err != nil {
|
|
t.Fatalf("EventsSince: %v", err)
|
|
}
|
|
if len(evs) == 0 {
|
|
t.Fatalf("EventsSince returned no durable events after creates")
|
|
}
|
|
|
|
bq, ok := beads.AsBlockedQuerier(store)
|
|
if !ok {
|
|
t.Fatalf("AsBlockedQuerier returned ok=false for a live Dolt Storage")
|
|
}
|
|
batch, err := bq.IsBlockedBatch(ctx, []string{"qi-src", "qi-target"})
|
|
if err != nil {
|
|
t.Fatalf("IsBlockedBatch: %v", err)
|
|
}
|
|
// qi-src blocks-depends on the open qi-target, so it is blocked; qi-target
|
|
// has no open blocker. The batch value must match per-row IsBlocked.
|
|
for _, id := range []string{"qi-src", "qi-target"} {
|
|
want, _, err := bq.IsBlocked(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("IsBlocked(%s): %v", id, err)
|
|
}
|
|
if batch[id] != want {
|
|
t.Fatalf("IsBlockedBatch[%s] = %v, want %v (per-row IsBlocked)", id, batch[id], want)
|
|
}
|
|
}
|
|
if !batch["qi-src"] {
|
|
t.Fatalf("IsBlockedBatch[qi-src] = false, want true (blocked by open qi-target)")
|
|
}
|
|
}
|
|
|
|
// TestAsAccessorsResolveThroughHookDecorator proves the decorator contract that
|
|
// lets the As* accessors use a single direct assertion (no UnwrapStore): a
|
|
// HookFiringStore embeds storage.DoltStorage, so the engine-interface narrow
|
|
// surfaces (claim / event feed / dependents) promote through it and resolve
|
|
// without unwrapping. This is the test that would go red if a future decorator
|
|
// stopped forwarding one of them — i.e. the reason the removed fallback was dead
|
|
// rather than load-bearing.
|
|
func TestAsAccessorsResolveThroughHookDecorator(t *testing.T) {
|
|
skipIfNoDoltServer(t)
|
|
|
|
ctx := context.Background()
|
|
store, err := beads.Open(ctx, filepath.Join(t.TempDir(), "deco-dolt"))
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ds, ok := store.(storage.DoltStorage)
|
|
if !ok {
|
|
t.Fatalf("live Dolt store is not a storage.DoltStorage")
|
|
}
|
|
// nil runner => passthrough decorator; the narrow surfaces are promoted from
|
|
// the embedded engine interface, not overridden.
|
|
decorated := storage.NewHookFiringStore(ds, nil)
|
|
|
|
if _, ok := beads.AsIssueClaimer(decorated); !ok {
|
|
t.Errorf("AsIssueClaimer returned ok=false through a HookFiringStore decorator")
|
|
}
|
|
if _, ok := beads.AsEventQuerier(decorated); !ok {
|
|
t.Errorf("AsEventQuerier returned ok=false through a HookFiringStore decorator")
|
|
}
|
|
if _, ok := beads.AsDependentQuerier(decorated); !ok {
|
|
t.Errorf("AsDependentQuerier returned ok=false through a HookFiringStore decorator")
|
|
}
|
|
if _, ok := beads.AsBlockedQuerier(decorated); !ok {
|
|
t.Errorf("AsBlockedQuerier returned ok=false through a HookFiringStore decorator")
|
|
}
|
|
}
|