Class: Insika::FunnelStore

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/funnel_store.rb

Overview

the durable aggregates of the OUTCOME FUNNEL — per-day stage counts (the fold's cells), the fold cursor and the baseline snapshot. A dumb domain store: it holds no outcome_store and never folds (C4 owns the transformation), and it is recomputable by construction — the OutcomeStore stays the source of truth.

Key shapes (string keys, Store-contract JSON):

"funnel"           "acme:store-support:2026-08-14"  -> { "greeted" => 41, "paid" => 3 }
"funnel_cursor"    "acme:store-support"             -> { "at" => ISO8601 | nil, "ids" => [uuid, …] }
"funnel_baseline"  "acme:store-support"             -> { from/to/stages/primary/…/frozen_at }

The no-tenant case is the literal "platform" (the outcome KEY's rule), so funnel and outcome keys share the same tenant segment and the purge prefix scan is the same string. A record's tenant field is tenant.to_s — "" for a single-tenant write — so every key-builder normalizes a blank tenant to "platform" HERE (one place), or a single-tenant fold lands in an ""-prefixed cell that purge("platform") never removes.

Constant Summary collapse

SCOPE =

day cells

"funnel"
CURSOR_SCOPE =

per (tenant, agent)

"funnel_cursor"
BASELINE_SCOPE =

per (tenant, agent)

"funnel_baseline"

Instance Method Summary collapse

Constructor Details

#initialize(store:) ⇒ FunnelStore

Returns a new instance of FunnelStore.



28
29
30
# File 'lib/insika/funnel_store.rb', line 28

def initialize(store:)
  @store = store
end

Instance Method Details

#add(tenant:, agent:, at:, counts:) ⇒ Object

Cumulative increment (D2): counts is the fold's { stage => 1 } hash for the reached prefix stages — the STORE stays free of the declaration (D1), it only accumulates. Bumped in ONE transaction. -> { stage => count } the NEW day counts (string keys, declared order).



36
37
38
39
40
41
42
43
44
45
# File 'lib/insika/funnel_store.rb', line 36

def add(tenant:, agent:, at:, counts:)
  id = pair_id(tenant, agent)
  day_key = day_segment(at)
  @store.transaction do
    cell = @store.get(SCOPE, "#{id}:#{day_key}") || {}
    counts.each { |stage, n| cell[stage.to_s] = cell[stage.to_s].to_i + n }
    @store.set(SCOPE, "#{id}:#{day_key}", cell)
    cell
  end
end

#baseline(tenant:, agent:) ⇒ Object

-> Hash | nil — the current baseline record (D5), read back verbatim.



75
76
77
# File 'lib/insika/funnel_store.rb', line 75

def baseline(tenant:, agent:)
  @store.get(BASELINE_SCOPE, pair_id(tenant, agent))
end

#cursor(tenant:, agent:) ⇒ Object

The fold cursor of the pair. -> { "at" => String | nil, "ids" => [String] }



65
66
67
68
# File 'lib/insika/funnel_store.rb', line 65

def cursor(tenant:, agent:)
  record = @store.get(CURSOR_SCOPE, pair_id(tenant, agent))
  { "at" => record && record["at"], "ids" => Array(record && record["ids"]) }
end

#day(tenant:, agent:, day:) ⇒ Object

-> { stage => count } | {} — one day's cell ("YYYY-MM-DD").



48
49
50
# File 'lib/insika/funnel_store.rb', line 48

def day(tenant:, agent:, day:)
  @store.get(SCOPE, "#{pair_id(tenant, agent)}:#{day}") || {}
end

#days(tenant:, agent:, from: nil, to: nil) ⇒ Object

-> { "YYYY-MM-DD" => { stage => count } } sorted ascending, bounded by ISO-date strings from:/to: (inclusive). Empty hash when none.



54
55
56
57
58
59
60
61
62
# File 'lib/insika/funnel_store.rb', line 54

def days(tenant:, agent:, from: nil, to: nil)
  prefix = "#{pair_id(tenant, agent)}:"
  @store.list(SCOPE).select { |k| k.start_with?(prefix) }.each_with_object({}) do |k, acc|
    day = k.delete_prefix(prefix)
    next unless within?(day, from, to)

    acc[day] = @store.get(SCOPE, k)
  end.sort.to_h
end

#delete_days(tenant:, agent:) ⇒ Object

wipes ONE pair's day cells — the recompute repair path (the fold rebuilds them from the outcome store, so it must start from zero, never sum on top). Same tenant normalization as every other key builder (""/nil/"platform" all reach the "platform" segment). -> count removed.



117
118
119
120
121
122
# File 'lib/insika/funnel_store.rb', line 117

def delete_days(tenant:, agent:)
  prefix = "#{pair_id(tenant, agent)}:"
  keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
  keys.each { |k| @store.delete(SCOPE, k) }
  keys.size
end

#delete_older_than(time) ⇒ Object

Age-based prune of DAY CELLS ONLY (retention, WS8 — outcomes and their fold die together). The day is the key's last segment ("YYYY-MM-DD", lexicographic). Cursors/baselines are tiny and live while their agent does. -> count removed.



100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/insika/funnel_store.rb', line 100

def delete_older_than(time)
  cutoff = time.utc.strftime("%Y-%m-%d")
  removed = 0
  @store.list(SCOPE).each do |k|
    day = k.rpartition(":").last
    next unless day < cutoff

    @store.delete(SCOPE, k)
    removed += 1
  end
  removed
end

#pairsObject

The pairs that have any day cell — the Studio's derived drill (D7). -> [{ tenant: String | nil, agent: String }]



126
127
128
# File 'lib/insika/funnel_store.rb', line 126

def pairs
  @store.list(SCOPE).map { |k| pair_of(k) }.uniq
end

#purge(tenant:) ⇒ Object

Purge one tenant: its day cells, cursors and baselines (DeleteTenantData, WS8 — the tenant is the FIRST key segment, so a prefix scan; the key IS the isolation). -> count removed.



87
88
89
90
91
92
93
94
# File 'lib/insika/funnel_store.rb', line 87

def purge(tenant:)
  prefix = "#{tenant_id(tenant)}:"
  [SCOPE, CURSOR_SCOPE, BASELINE_SCOPE].sum do |scope|
    keys = @store.list(scope).select { |k| k.start_with?(prefix) }
    keys.each { |k| @store.delete(scope, k) }
    keys.size
  end
end

#set_baseline(tenant:, agent:, record:) ⇒ Object

Overwrites (D5 — one current snapshot per pair, no history).



80
81
82
# File 'lib/insika/funnel_store.rb', line 80

def set_baseline(tenant:, agent:, record:)
  @store.set(BASELINE_SCOPE, pair_id(tenant, agent), record)
end

#set_cursor(tenant:, agent:, at:, ids:) ⇒ Object



70
71
72
# File 'lib/insika/funnel_store.rb', line 70

def set_cursor(tenant:, agent:, at:, ids:)
  @store.set(CURSOR_SCOPE, pair_id(tenant, agent), { "at" => at, "ids" => Array(ids) })
end