Skip to content

Commit c57ff1e

Browse files
authored
refactor(core): split ReadingService into Reader/Graph/Map/Editor
2 parents 0e93f88 + 03b48b0 commit c57ff1e

7 files changed

Lines changed: 145 additions & 43 deletions

File tree

internal/adapter/inbound/web/reading_handlers.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,8 @@ func (h *ReadingHandler) Root(c *echo.Context) error {
115115
func (h *ReadingHandler) Doc(c *echo.Context) error {
116116
world := c.Param("world")
117117
p := "/" + c.Param("*")
118-
if strings.HasSuffix(p, "/") {
119-
doc, err := h.reading.Browse(c.Request().Context(), world, p)
120-
return h.present(c, doc, err, viewOpts{world: world, path: p})
121-
}
122-
doc, err := h.reading.Read(c.Request().Context(), world, p)
123-
return h.present(c, doc, err, viewOpts{world: world, path: p, doc: true})
118+
doc, err := h.reading.Open(c.Request().Context(), world, p)
119+
return h.present(c, doc, err, viewOpts{world: world, path: p, doc: !domain.IsListingPath(p)})
124120
}
125121

126122
// Search renders the card catalog (LOOKUP) for the q query in the route's

internal/adapter/inbound/web/reading_handlers_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,23 @@ func (f *fakeReading) TagCached(_ context.Context, _, tag string) (domain.Docume
9393
return f.record("TagCached", tag)
9494
}
9595

96+
// Open/OpenCached mirror the service: dispatch to Read/Browse by path shape, so
97+
// tests that assert the recorded Read/Browse calls still hold after the handler
98+
// addresses resources through Open.
99+
func (f *fakeReading) Open(ctx context.Context, world, path string) (domain.Document, error) {
100+
if domain.IsListingPath(path) {
101+
return f.Browse(ctx, world, path)
102+
}
103+
return f.Read(ctx, world, path)
104+
}
105+
106+
func (f *fakeReading) OpenCached(ctx context.Context, world, path string) (domain.Document, error) {
107+
if domain.IsListingPath(path) {
108+
return f.BrowseCached(ctx, world, path)
109+
}
110+
return f.ReadCached(ctx, world, path)
111+
}
112+
96113
// RecordLinks is a write, not a read: it stays out of calls/called so the
97114
// focused-live read-budget assertions keep measuring only world reads.
98115
func (f *fakeReading) RecordLinks(_, path string, targets []domain.Ref) {

internal/adapter/inbound/web/trail_handlers.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,10 @@ func (h *ReadingHandler) readPane(ctx context.Context, addr paneAddr, live bool)
194194
return h.reading.Tag(ctx, addr.World, addr.Value)
195195
case addr.Kind == paneTag:
196196
return h.reading.TagCached(ctx, addr.World, addr.Value)
197-
case strings.HasSuffix(addr.Value, "/") && live:
198-
return h.reading.Browse(ctx, addr.World, addr.Value)
199-
case strings.HasSuffix(addr.Value, "/"):
200-
return h.reading.BrowseCached(ctx, addr.World, addr.Value)
201197
case live:
202-
return h.reading.Read(ctx, addr.World, addr.Value)
198+
return h.reading.Open(ctx, addr.World, addr.Value)
203199
default:
204-
return h.reading.ReadCached(ctx, addr.World, addr.Value)
200+
return h.reading.OpenCached(ctx, addr.World, addr.Value)
205201
}
206202
}
207203

@@ -247,7 +243,7 @@ func (h *ReadingHandler) paneView(t trail, i int, addr paneAddr, doc domain.Docu
247243
}
248244

249245
content, edges := rewriteLinks(doc.HTML, addr.World, doc.Path)
250-
if !reader && addr.Kind == paneDoc && !strings.HasSuffix(addr.Value, "/") {
246+
if !reader && addr.Kind == paneDoc && !domain.IsListingPath(addr.Value) {
251247
// Feed the observed-links map (R3) from real document panes only —
252248
// listings and tag pages are not edge sources. This runs for the
253249
// focused pane and its body-only parent, so a doc's edges are recorded
@@ -265,7 +261,7 @@ func (h *ReadingHandler) paneView(t trail, i int, addr paneAddr, doc domain.Docu
265261

266262
// The overlay (reader) shows the addressed pane's full margin even when it
267263
// is not the focused pane — reading mode is not a dead-end.
268-
if (focused || reader) && addr.Kind == paneDoc && !strings.HasSuffix(addr.Value, "/") {
264+
if (focused || reader) && addr.Kind == paneDoc && !domain.IsListingPath(addr.Value) {
269265
vm.HasMargin = true
270266
vm.Tags = tagLinks(addr.World, doc.Tags)
271267
vm.Properties = doc.Properties

internal/core/domain/addressing.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package domain
2+
3+
import "strings"
4+
5+
// IsListingPath reports whether a (world, path) addresses a directory listing
6+
// (the stacks) rather than a document. The convention: a path ending in "/" is a
7+
// listing, anything else is a document. This is the single definition of that
8+
// addressing rule — the read dispatch (service.Open/OpenCached) and the web
9+
// adapter's margin/edge-source presentation both consult it, so the two never
10+
// drift.
11+
func IsListingPath(path string) bool {
12+
return strings.HasSuffix(path, "/")
13+
}

internal/core/port/port.go

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,31 @@ import (
99
"github.com/latebit-io/demarkus-library/internal/core/domain"
1010
)
1111

12-
// ReadingService is the inbound (driving) port — the use cases the reading room
13-
// exposes to its primary adapters (the web adapter). Driving adapters depend on
14-
// this interface, not on the concrete service.
12+
// The inbound (driving) port is split into four concerns — Reader, GraphService,
13+
// MapService, Editor — and composed as ReadingService, the full surface the web
14+
// adapter drives. The concrete *service.ReadingService satisfies all four; a
15+
// narrower consumer (a preview-only handler, the Phase 4 librarian needing only
16+
// reads) can depend on just the slice it uses rather than the whole 20-method
17+
// surface.
1518
//
16-
// Every method takes the request context (cancellation + the logged-in
17-
// reader's bearer in broker mode, Phase 1b/ADR 0004) and a world: the library
18-
// spans a universe of worlds, and a document's address is (world, path). A
19-
// world is either a knowledge-system world name (resolved by the broker) or a
19+
// Every context-taking method takes the request context (cancellation + the
20+
// logged-in reader's bearer in broker mode, Phase 1b/ADR 0004) and a world: the
21+
// library spans a universe of worlds, and a document's address is (world, path).
22+
// A world is either a knowledge-system world name (resolved by the broker) or a
2023
// demarkus host[:port] reached directly — the distributed knowledge graph is
2124
// traversable across both.
22-
type ReadingService interface {
25+
26+
// Reader is the read side: fetch and render documents, listings, editions, and
27+
// the catalog, plus the trail engine's cached variants.
28+
type Reader interface {
2329
// Read fetches and renders the document at (world, path).
2430
Read(ctx context.Context, world, path string) (domain.Document, error)
2531
// Browse renders a directory listing (the stacks) at (world, path).
2632
Browse(ctx context.Context, world, path string) (domain.Document, error)
33+
// Open reads (world, path), dispatching to Browse for a listing path and
34+
// Read for a document (domain.IsListingPath) — so callers address a
35+
// resource without re-deciding the listing-vs-document rule themselves.
36+
Open(ctx context.Context, world, path string) (domain.Document, error)
2737
// History renders the edition history of the document at (world, path).
2838
History(ctx context.Context, world, path string) (domain.Document, error)
2939
// Search renders the card catalog (LOOKUP) results for query under scope
@@ -36,22 +46,28 @@ type ReadingService interface {
3646
// the projection's escape to protocol (ADR 0005 decision 12).
3747
Raw(ctx context.Context, world, path string) (domain.RawDocument, error)
3848

39-
// ReadCached, BrowseCached, and TagCached are the trail engine's
40-
// unfocused-pane reads (ADR 0005 decision 9): served from the
49+
// ReadCached, BrowseCached, OpenCached, and TagCached are the trail
50+
// engine's unfocused-pane reads (ADR 0005 decision 9): served from the
4151
// rendered-document cache, reading through on a miss. The focused pane
4252
// uses the live methods, which refresh the cache — so a trail click
4353
// costs exactly one world read. Without a cache wired they behave as
44-
// their live counterparts.
54+
// their live counterparts. OpenCached dispatches by path shape like Open.
4555
ReadCached(ctx context.Context, world, path string) (domain.Document, error)
4656
BrowseCached(ctx context.Context, world, path string) (domain.Document, error)
57+
OpenCached(ctx context.Context, world, path string) (domain.Document, error)
4758
TagCached(ctx context.Context, world, tag string) (domain.Document, error)
59+
}
4860

61+
// GraphService is the render-time observed-links graph (R3; ADR 0005 §16): the
62+
// web adapter records resolved links, the core owns the edge store and answers
63+
// backlink / neighborhood queries.
64+
type GraphService interface {
4965
// RecordLinks records the in-universe document links observed in the
50-
// rendered document at (world, path), replacing any prior observation
51-
// (R3; ADR 0005 §16). The web adapter calls this after resolving links
52-
// (rewriteLinks owns the URL scheme); the core owns the edge store. This
53-
// is the render-time observed-links map that feeds Backlinks and
54-
// Neighborhood — transport-symmetric, no broker graph store required.
66+
// rendered document at (world, path), replacing any prior observation. The
67+
// web adapter calls this after resolving links (rewriteLinks owns the URL
68+
// scheme); the core owns the edge store. This is the render-time
69+
// observed-links map that feeds Backlinks and Neighborhood —
70+
// transport-symmetric, no broker graph store required.
5571
RecordLinks(world, path string, targets []domain.Ref)
5672
// Backlinks returns the documents observed linking to (world, path) — the
5773
// margin's "referenced by" block and the graph pane's inbound edges.
@@ -62,25 +78,30 @@ type ReadingService interface {
6278
// document plus its observed outbound and inbound edges. Store-only (zero
6379
// world reads), so it works cold in both transports.
6480
Neighborhood(world, path string) domain.Neighborhood
81+
}
6582

66-
// Floor assembles the universe view's data (ADR 0005 decision 4):
67-
// the authorized worlds and each world's top-importance catalog
68-
// entries. Live rebuild; FloorCached serves the last build when the
69-
// floor pane is unfocused (the same focused-live policy as documents).
83+
// MapService assembles the spatial views (ADR 0005 decision 4): the universe
84+
// floor and one-world map, each with a live build and a cached variant for
85+
// unfocused panes (the focused-live policy every pane follows).
86+
type MapService interface {
87+
// Floor assembles the universe view's data: the authorized worlds and each
88+
// world's top-importance catalog entries. Live rebuild; FloorCached serves
89+
// the last build when the floor pane is unfocused.
7090
Floor(ctx context.Context) (domain.Floor, error)
7191
FloorCached(ctx context.Context) (domain.Floor, error)
72-
73-
// WorldMap assembles the world-view zoom (ADR 0005 decision 4 — the floor
74-
// one zoom in): one world's catalog grouped into directory clusters with
75-
// the intra-world edges among the rendered documents. Live rebuild;
76-
// WorldMapCached serves the last build for an unfocused/parent pane (the
77-
// focused-live policy every pane follows).
92+
// WorldMap assembles the world-view zoom (the floor one zoom in): one
93+
// world's catalog grouped into directory clusters with the intra-world
94+
// edges among the rendered documents. Live rebuild; WorldMapCached serves
95+
// the last build for an unfocused/parent pane.
7896
WorldMap(ctx context.Context, world string) (domain.WorldMap, error)
7997
WorldMapCached(ctx context.Context, world string) (domain.WorldMap, error)
98+
}
8099

81-
// EditDraft fetches the source view for the cataloging desk's edit form
82-
// (Phase 3): the document's raw markdown plus its current metadata and
83-
// version, so the editor pre-fills exactly what the catalog holds.
100+
// Editor is the cataloging desk's write side (Phase 3).
101+
type Editor interface {
102+
// EditDraft fetches the source view for the edit form: the document's raw
103+
// markdown plus its current metadata and version, so the editor pre-fills
104+
// exactly what the catalog holds.
84105
EditDraft(ctx context.Context, world, path string) (domain.EditDraft, error)
85106
// Preview renders edit-buffer markdown to sanitized HTML for the desk's
86107
// live preview — the same renderer the reader uses, so what you see is what
@@ -99,6 +120,16 @@ type ReadingService interface {
99120
Append(ctx context.Context, world, path, body string) (domain.Document, error)
100121
}
101122

123+
// ReadingService is the full inbound surface the web adapter drives — the four
124+
// concerns composed. Driving adapters depend on this (or a narrower slice), not
125+
// on the concrete service.
126+
type ReadingService interface {
127+
Reader
128+
GraphService
129+
MapService
130+
Editor
131+
}
132+
102133
// WorldGateway is an outbound (driven) port — read from demarkus worlds. The
103134
// adapter translates transport status into domain errors and returns markdown
104135
// bodies for the core to render. Implementations: direct QUIC (world is a

internal/core/service/hardening_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,35 @@ import (
88
"github.com/latebit-io/demarkus-library/internal/core/domain"
99
)
1010

11+
// TestOpenDispatchesByPathShape: Open routes a document path to Read (Fetch) and
12+
// a listing path to Browse (List); OpenCached dispatches the same way. The
13+
// gateway verb the service hit is the witness (Read→Fetch, Browse→List).
14+
func TestOpenDispatchesByPathShape(t *testing.T) {
15+
var called string
16+
svc := NewReadingService(
17+
fakeGateway{called: &called, raw: domain.RawDocument{Body: "x"}},
18+
fakeRenderer{html: "x"}, nil,
19+
)
20+
cases := []struct {
21+
name, path, wantVerb string
22+
open func(string) (domain.Document, error)
23+
}{
24+
{"Open doc", "/doc.md", "Fetch", func(p string) (domain.Document, error) { return svc.Open(t.Context(), "w", p) }},
25+
{"Open listing", "/dir/", "List", func(p string) (domain.Document, error) { return svc.Open(t.Context(), "w", p) }},
26+
{"OpenCached doc", "/doc.md", "Fetch", func(p string) (domain.Document, error) { return svc.OpenCached(t.Context(), "w", p) }},
27+
{"OpenCached listing", "/dir/", "List", func(p string) (domain.Document, error) { return svc.OpenCached(t.Context(), "w", p) }},
28+
}
29+
for _, tc := range cases {
30+
called = ""
31+
if _, err := tc.open(tc.path); err != nil {
32+
t.Fatalf("%s: %v", tc.name, err)
33+
}
34+
if called != tc.wantVerb {
35+
t.Errorf("%s hit gateway %q, want %q", tc.name, called, tc.wantVerb)
36+
}
37+
}
38+
}
39+
1140
// TestWorldMapCacheTTLAndInvalidate covers the cache primitive directly: a put
1241
// is fresh within the TTL, stale at/after it, and invalidate drops it outright.
1342
func TestWorldMapCacheTTLAndInvalidate(t *testing.T) {

internal/core/service/reading.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ func (s *ReadingService) Read(ctx context.Context, world, path string) (domain.D
7171
return doc, nil
7272
}
7373

74+
// Open reads (world, path), dispatching to Browse for a listing path and Read
75+
// for a document. The single read-side owner of the listing-vs-document
76+
// addressing rule (domain.IsListingPath) — callers address a resource without
77+
// re-deciding it. OpenCached is its cached counterpart for unfocused panes.
78+
func (s *ReadingService) Open(ctx context.Context, world, path string) (domain.Document, error) {
79+
if domain.IsListingPath(path) {
80+
return s.Browse(ctx, world, path)
81+
}
82+
return s.Read(ctx, world, path)
83+
}
84+
85+
// OpenCached is Open served from the rendered-document cache (trail engine's
86+
// unfocused panes), dispatching Browse/Read by path shape like Open.
87+
func (s *ReadingService) OpenCached(ctx context.Context, world, path string) (domain.Document, error) {
88+
if domain.IsListingPath(path) {
89+
return s.BrowseCached(ctx, world, path)
90+
}
91+
return s.ReadCached(ctx, world, path)
92+
}
93+
7494
// ReadCached serves (world, path) from the rendered-document cache, reading
7595
// through on a miss. Trail engine: unfocused panes only — the focused pane
7696
// goes through Read, which refreshes the entry (focused-live policy).

0 commit comments

Comments
 (0)