<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[soroi]]></title><description><![CDATA[soroi]]></description><link>https://soroi.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 05:02:13 GMT</lastBuildDate><atom:link href="https://soroi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Keeping a Bubble Tea Dashboard in Sync with soroi]]></title><description><![CDATA[Share incident queries across Bubble Tea panes with soroi, refresh after acknowledgements, and keep cached data visible when refreshes fail.]]></description><link>https://soroi.hashnode.dev/keeping-a-bubble-tea-dashboard-in-sync-with-soroi</link><guid isPermaLink="true">https://soroi.hashnode.dev/keeping-a-bubble-tea-dashboard-in-sync-with-soroi</guid><category><![CDATA[golang]]></category><category><![CDATA[Bubbletea]]></category><category><![CDATA[caching]]></category><dc:creator><![CDATA[r0tshk6dim]]></dc:creator><pubDate>Fri, 23 Oct 2026 17:56:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad4e4f12a16ec5038d10de4/d4328668-9bed-4b89-a370-552be0d33cfd.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>An incident console gets more demanding once it has several panes. The queue shows open incidents, a detail pane follows the selected row, and a summary groups incidents by severity. A responder acknowledges an incident, and the new owner needs to appear everywhere that displays it.</p>
<p><a href="https://github.com/Waterkyuu/soroi">GitHub: Waterkyuu/soroi</a></p>
<p>Bubble Tea gives the screen a clear message loop. The application still has to decide who loads the queue, which results can be reused, and what each pane should show while another read is running.</p>
<p>soroi is a typed query client for Go. It fits this part of a terminal application: several components reading the same remote data, with writes that make the cached view stale. The incident service still owns the actual HTTP requests and acknowledgement rules.</p>
<h2>A queue that several panes can read</h2>
<p>Suppose each pane is a separate component with its own subscription lifetime. They all need the open queue for the same team, so they can use the same client, key, and loader:</p>
<pre><code class="language-go">client := soroi.NewClient(soroi.Options{
    StaleTime: 5 * time.Second,
    Timeout:   5 * time.Second,
})

key := fmt.Sprintf("team:%s:incidents:open", teamID)
load := func(ctx context.Context) ([]Incident, error) {
    return api.ListOpen(ctx, teamID)
}

panels := map[string]*soroi.QueryHandle[[]Incident]{
    "list":    soroi.Query(client, key, load),
    "detail":  soroi.Query(client, key, load),
    "summary": soroi.Query(client, key, load),
}
</code></pre>
<p>Within this client, overlapping loads for that key share the same work. A pane that subscribes while the result is still fresh can reuse it. The detail pane can select an incident from this dataset without making another request for fields the queue already contains.</p>
<p>The team ID belongs in the key. A different team, permission scope, or server-side filter needs a distinct key when it changes the result. Also, keep the cached slice read-only; an acknowledgement should go through the service.</p>
<p>When every pane always lives and closes with the parent screen, a single parent subscription can be simpler. Independent handles are useful when the components have their own lifetimes.</p>
<h2>Let Bubble Tea receive the changes</h2>
<p>A query subscription needs a small bridge into Bubble Tea. Each command waits for one notification and returns a message. It never changes the model from a background goroutine.</p>
<pre><code class="language-go">// incidentsMsg carries one pane's query state onto the UI loop.
type incidentsMsg struct {
    // panel identifies the component receiving this snapshot.
    panel string
    // state contains read-only data and the current loading result.
    state soroi.Snapshot[[]Incident]
}

// waitForIncidents delivers one notification to Bubble Tea.
func waitForIncidents(panel string, query *soroi.QueryHandle[[]Incident]) tea.Cmd {
    return func() tea.Msg {
        state, ok := &lt;-query.Updates()
        if !ok {
            return nil
        }
        return incidentsMsg{panel: panel, state: state}
    }
}
</code></pre>
<p>The screen starts a receive for each mounted pane:</p>
<pre><code class="language-go">// Init starts the mounted panes' subscription commands.
func (m incidentBoard) Init() tea.Cmd {
    return tea.Batch(
        waitForIncidents("list", m.panels["list"]),
        waitForIncidents("detail", m.panels["detail"]),
        waitForIncidents("summary", m.panels["summary"]),
    )
}
</code></pre>
<p>When <code>Update</code> receives a snapshot, it stores it and returns the next receive command for that pane:</p>
<pre><code class="language-go">case incidentsMsg:
    m.states[msg.panel] = msg.state
    if msg.panel == "list" &amp;&amp; m.selectedID == "" &amp;&amp; len(msg.state.Data) &gt; 0 {
        m.selectedID = msg.state.Data[0].ID
    }
    return m, waitForIncidents(msg.panel, m.panels[msg.panel])
</code></pre>
<p>That last return keeps the subscription going after the initial load. The queue and detail can briefly show different phases because their messages are processed separately. These notifications carry the latest query state; intermediate states can be skipped. Incident history belongs in the service's audit trail.</p>
<h2>Keep the queue visible when a refresh fails</h2>
<p>During an incident, a failed refresh should leave the responder with the last known queue and a visible error. <code>HasData</code>, <code>Fetching</code>, and <code>Err</code> let the screen express that directly:</p>
<pre><code class="language-go">// renderIncidents preserves the queue while reporting refresh failures.
func renderIncidents(state soroi.Snapshot[[]Incident]) string {
    var out strings.Builder
    switch {
    case !state.HasData &amp;&amp; state.Err != nil:
        fmt.Fprintf(&amp;out, "Could not load incidents: %v\n", state.Err)
    case !state.HasData:
        out.WriteString("Loading incidents...\n")
    case state.Err != nil:
        fmt.Fprintf(&amp;out, "Refresh failed; showing previous incidents: %v\n", state.Err)
    case state.Fetching:
        out.WriteString("Refreshing incidents...\n")
    }
    for _, incident := range state.Data {
        fmt.Fprintf(&amp;out, "%s  %s  %s  %s\n",
            incident.ID, incident.Severity, incident.Service, incident.Owner)
    }
    return out.String()
}
</code></pre>
<p>An initial failure has no queue to display. A later failure can retain the previous data. Keeping those cases separate avoids turning a temporary API outage into an empty incident list that looks like everything has been resolved.</p>
<h2>Acknowledge an incident, then reload the queue</h2>
<p>The acknowledgement is an HTTP write. It belongs in a <code>tea.Cmd</code> so that moving around the terminal stays responsive while the request runs.</p>
<pre><code class="language-go">// acknowledgedMsg reports the result of the acknowledgement request.
type acknowledgedMsg struct {
    // err is the service or transport error, if the write failed.
    err error
}

// acknowledge runs the business write outside the UI loop.
func acknowledge(parent context.Context, api *incidentAPI, id string) tea.Cmd {
    return func() tea.Msg {
        ctx, cancel := context.WithTimeout(parent, 3*time.Second)
        defer cancel()
        return acknowledgedMsg{err: api.Acknowledge(ctx, id)}
    }
}
</code></pre>
<p>On completion, <code>Update</code> clears the pending state and invalidates the shared queue only if the write succeeded:</p>
<pre><code class="language-go">case acknowledgedMsg:
    m.saving = false
    m.actionErr = msg.err
    if msg.err == nil {
        m.actionErr = m.client.Invalidate(m.key)
    }
    return m, nil
</code></pre>
<p>Invalidation marks the queue stale and starts a refresh for the active observers. The panes keep their previous incidents while that read runs, then receive the updated owner through their subscriptions.</p>
<p>The key handler prevents repeated acknowledgements while one is pending. Pressing <code>r</code> requests a refresh of the same queue:</p>
<pre><code class="language-go">case "a":
    if m.saving || m.selectedID == "" {
        return m, nil
    }
    m.saving = true
    m.actionErr = nil
    return m, acknowledge(m.ctx, m.api, m.selectedID)
case "r":
    m.actionErr = m.client.Invalidate(m.key)
    return m, nil
</code></pre>
<p>The five-second freshness window doesn't poll the service. Changes made by other responders become visible after a later read or explicit invalidation. A live feed or polling schedule would still need to trigger that work. If the screen also loads a separate incident timeline, invalidate its key when the write affects it; soroi doesn't infer that relationship.</p>
<h2>Keep selection and cleanup predictable</h2>
<p>A refreshed queue can come back in a different order. The selected incident should be tracked by ID, so that a responder doesn't accidentally acknowledge a different row after a refresh. The detail pane resolves that ID against its own snapshot:</p>
<pre><code class="language-go">state := m.states["detail"]
if !state.HasData {
    return "Waiting for incident details..."
}
if m.selectedID == "" {
    return "Select an incident."
}
for _, incident := range state.Data {
    if incident.ID == m.selectedID {
        return fmt.Sprintf("%s: %s\nOwner: %s",
            incident.ID, incident.Service, incident.Owner)
    }
}
return "The selected incident is no longer in the open queue."
</code></pre>
<p>The application also owns the client and the handles it creates. Release them on terminal exit and on startup failure:</p>
<pre><code class="language-go">// runBoard releases subscriptions and shared work when the terminal exits.
func runBoard(api *incidentAPI, teamID string) error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    client := soroi.NewClient(soroi.Options{StaleTime: 5 * time.Second})
    defer client.Close()

    board := newBoard(ctx, api, client, teamID)
    for _, query := range board.panels {
        defer query.Close()
    }
    _, err := tea.NewProgram(board, tea.WithContext(ctx)).Run()
    return err
}
</code></pre>
<p>Closing a handle releases its waiting receive command. Closing the shared client cancels its query work. Business commands use the program's context too, so an acknowledgement doesn't keep running indefinitely after the operator exits.</p>
<p>This pattern fits terminal tools whose components have independent lifetimes and keep reading the same remote data. A screen with one data consumer can stay with a regular <code>tea.Cmd</code>; there's little shared query work to coordinate.</p>
]]></content:encoded></item><item><title><![CDATA[soroi: Shared Queries and Cache Refreshes for Go Services]]></title><description><![CDATA[Share cached product data, merge overlapping reads, and refresh queries after writes with soroi, a typed query client for Go.]]></description><link>https://soroi.hashnode.dev/soroi-shared-queries-and-cache-refreshes-for-go-services</link><guid isPermaLink="true">https://soroi.hashnode.dev/soroi-shared-queries-and-cache-refreshes-for-go-services</guid><category><![CDATA[Go Language]]></category><category><![CDATA[Backend Development]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[r0tshk6dim]]></dc:creator><pubDate>Sun, 18 Oct 2026 15:29:40 GMT</pubDate><content:encoded><![CDATA[<p>A product page loads a product. The cart loads it again. If the requests overlap, the backend may read the same record twice. After an admin changes the price, there is another job to remember: retiring the old detail and category-list cache entries.</p>
<p>None of this is especially difficult. It becomes tedious when the same loading and refresh logic turns up in several services.</p>
<p>soroi is a typed query client for Go that handles cached results, shared in-flight loads, and refresh state. Its core has no external dependencies and supports Go 1.22 and later.</p>
<p><a href="https://github.com/Waterkyuu/soroi">GitHub: Waterkyuu/soroi</a></p>
<p>A storefront gives these features a practical setting. The data access stays in the application's product repository; soroi coordinates how callers share its results.</p>
<p>Product details are often read repeatedly. A short freshness window lets the application reuse those reads. Create a Client at startup and inject it into the services that need it:</p>
<pre><code class="language-go">queries := soroi.NewClient(soroi.Options{
	StaleTime: 30 * time.Second,
})
defer queries.Close()
</code></pre>
<p>The product endpoint can keep using its existing repository:</p>
<pre><code class="language-go">key := fmt.Sprintf("tenant:%d:product:%d", tenantID, productID)

product, err := soroi.Fetch(
	ctx,
	queries,
	key,
	func(ctx context.Context) (Product, error) {
		return productRepo.FindByID(ctx, tenantID, productID)
	},
)
if err != nil {
	return Product{}, err
}
</code></pre>
<p>A cart lookup using the same Client and key can reuse the fresh product. If the first load is still running, overlapping callers can join it. The returned value stays a Product, so callers do not need to extract an any value and assert its type.</p>
<p>The key needs to match the business scope. Include the tenant and product ID, plus currency or customer tier whenever they change the result.</p>
<p>Price changes are where the refresh work usually spreads. Updating a product can affect both its detail and its category listing. Keeping those relationships beside the successful write makes them easier to find later:</p>
<pre><code class="language-go">changePrice := soroi.Mutation(
	queries,
	func(ctx context.Context, change PriceChange) (Product, error) {
		return productRepo.ChangePrice(ctx, tenantID, change)
	},
	func(_ context.Context, saved Product, change PriceChange) error {
		return queries.Invalidate([]any{
			fmt.Sprintf("tenant:%d:product:%d", tenantID, saved.ID),
			fmt.Sprintf("tenant:%d:category:%d", tenantID, change.CategoryID),
		})
	},
)
defer changePrice.Close()

result, err := changePrice.Mutate(ctx, input)
if err != nil {
	return Product{}, err
}
if result.SyncErr != nil {
	slog.ErrorContext(ctx, "price cache synchronization failed",
		"product_id", result.Data.ID, "error", result.SyncErr)
}
</code></pre>
<p>Once the repository commits the write, the selected queries become stale. Ordinary endpoints reload them on the next read. Queries with enabled subscriptions can refresh in the background.</p>
<p>The separate synchronization error matters here: the price change may already have succeeded. A cache repair failure is a reason to repair the cache, rather than submit the business write again.</p>
<p>An admin page may also need to show the new price without a manual reload. If the application already uses server-sent events, a query subscription can feed that endpoint with product data and refresh state:</p>
<pre><code class="language-go">query := soroi.Query(queries, key,
	func(ctx context.Context) (Product, error) {
		return productRepo.FindByID(ctx, tenantID, productID)
	})
defer query.Close()

w.Header().Set("Content-Type", "text/event-stream")
response := http.NewResponseController(w)

for {
	select {
	case &lt;-r.Context().Done():
		return
	case state, ok := &lt;-query.Updates():
		if !ok {
			return
		}
		payload, err := json.Marshal(map[string]any{
			"product":    state.Data,
			"hasData":    state.HasData,
			"refreshing": state.Fetching,
			"failed":     state.Err != nil,
		})
		if err != nil {
			return
		}
		if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil {
			return
		}
		if err := response.Flush(); err != nil {
			return
		}
	}
}
</code></pre>
<p>The page can keep displaying the existing product while a refresh runs. If the refresh fails, it can retain that content and show an error instead of emptying the panel.</p>
<p>This flow shares a Client within one process. Subscriptions carry the latest query state and may skip intermediate changes. Order events and audit records that must be preserved individually still belong in the application's event flow.</p>
<p>Carts introduce another familiar problem: a list of product IDs that needs product details. When the repository supports a bulk read, the application can submit the known IDs together:</p>
<pre><code class="language-go">batcher, err := soroi.NewBatcher(
	ctx,
	func(ctx context.Context, ids []int64) (map[int64]soroi.BatchResult[Product], error) {
		found, err := productRepo.FindByIDs(ctx, tenantID, ids)
		if err != nil {
			return nil, err
		}
		results := make(map[int64]soroi.BatchResult[Product], len(ids))
		for _, id := range ids {
			product, ok := found[id]
			item := soroi.BatchResult[Product]{Data: product}
			if !ok {
				item.Err = ErrProductNotFound
			}
			results[id] = item
		}
		return results, nil
	},
	soroi.BatchOptions{MaxBatchSize: 100},
)
if err != nil {
	return nil, err
}
defer batcher.Close()

items := batcher.LoadMany(ctx, productIDs)
</code></pre>
<p>FindByIDs still performs the actual bulk SQL query or product-service request. The results follow the input order, and each item carries its own data or error. The cart can handle a missing product without confusing it with a failure of the whole backend request.</p>
<p>This is useful for order aggregation and product enrichment. It organizes the loads around a bulk operation the data source already supports; it does not turn a loop of individual SQL queries into one query. LoadMany does not retain completed results as a cache.</p>
<p>Remote inventory reads need a different policy from product descriptions. A cart may tolerate a few seconds of cached stock, one retry after a network timeout, and a firm limit on the time spent waiting:</p>
<pre><code class="language-go">freshness := 3 * time.Second
timeout := 2 * time.Second
retries := 1

stock, err := soroi.Fetch(
	ctx,
	queries,
	fmt.Sprintf("warehouse:%d:sku:%d", warehouseID, skuID),
	func(ctx context.Context) (Stock, error) {
		return inventoryAPI.GetStock(ctx, warehouseID, skuID)
	},
	soroi.QueryOptions{
		StaleTime: &amp;freshness,
		Timeout:   &amp;timeout,
		Retry:     &amp;retries,
		RetryIf: func(err error) bool {
			var networkErr net.Error
			return errors.As(err, &amp;networkErr) &amp;&amp; networkErr.Timeout()
		},
		RetryDelay: func(int) time.Duration {
			return 100 * time.Millisecond
		},
	},
)
if err != nil {
	return Stock{}, err
}
</code></pre>
<p>A load initiated with this policy has a two-second budget, including retry waits, and retries a network timeout once. A caller joining an existing load uses that load's original policy. The inventory client must pass the context into its actual request for cancellation to work.</p>
<p>The short cache is for displaying inventory. Checkout validation and stock deduction still go through the inventory service's authoritative write path. Query retry settings also do not automatically retry the earlier price mutation.</p>
<p>After adoption, the existing application logs can show whether these shared reads are helping:</p>
<pre><code class="language-go">stats := queries.Stats()

slog.InfoContext(ctx, "product query statistics",
	"cache_hits", stats.CacheHits,
	"merged_requests", stats.MergedRequests,
	"loads", stats.Loads,
	"load_failures", stats.LoadFailures,
)
</code></pre>
<p>These counters describe query activity for one Client. Actual SQL and HTTP call counts should still be recorded in the data access layer, especially when several query loads share a bulk callback.</p>
<p>soroi fits Go applications where several modules need the same data and writes need to trigger refreshes. Existing repositories and remote clients can stay in place while repeated query-management logic moves into a shared Client.</p>
]]></content:encoded></item></channel></rss>