package store import ( "sort" "strconv" "strings" "time" "github.com/google/uuid" ) var _ Backend = (*Memory)(nil) func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) { if in == nil || strings.TrimSpace(in.Type) == "" || strings.TrimSpace(in.Name) == "" { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[tenantID]; !ok { return nil, ErrTenantScope } id := uuid.NewString() mod := &Module{ ID: id, TenantID: tenantID, Type: in.Type, Name: strings.TrimSpace(in.Name), Enabled: in.Enabled, Priority: in.Priority, RefreshIntervalSec: in.RefreshIntervalSec, CronExpr: in.CronExpr, DefaultCommunityID: in.DefaultCommunityID, DohProfileIDs: append([]string(nil), in.DohProfileIDs...), DohResolverPolicy: in.DohResolverPolicy, LastRefreshedAt: in.LastRefreshedAt, CreatedByUserID: strings.TrimSpace(in.CreatedByUserID), } NormalizeModuleDoh(mod) m.modules[id] = mod return cloneModule(mod), nil } func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, ok := m.modules[moduleID] if !ok || mod.DeletedAt != nil || mod.TenantID != tenantID { return nil, ErrNotFound } if patch.Name != nil { mod.Name = strings.TrimSpace(*patch.Name) } if patch.Enabled != nil { mod.Enabled = *patch.Enabled } if patch.Priority != nil { mod.Priority = *patch.Priority } if patch.RefreshIntervalSec != nil { mod.RefreshIntervalSec = *patch.RefreshIntervalSec } if patch.CronExpr != nil { mod.CronExpr = *patch.CronExpr } if patch.DefaultCommunityID != nil { v := strings.TrimSpace(*patch.DefaultCommunityID) if v == "" { mod.DefaultCommunityID = nil } else { mod.DefaultCommunityID = &v } } if patch.DohProfileIDs != nil || patch.DohProfileID != nil || patch.DohResolverPolicy != nil { ApplyModuleDohPatch(mod, patch) } if patch.LastRefreshedAt != nil { t := patch.LastRefreshedAt.UTC() mod.LastRefreshedAt = &t } mod.InputHash = "" return cloneModule(mod), nil } func (m *Memory) SoftDeleteModule(tenantID, moduleID string) error { m.mu.Lock() defer m.mu.Unlock() if m.moduleSnapshots != nil { delete(m.moduleSnapshots, moduleSnapshotKey(tenantID, moduleID)) } mod, ok := m.modules[moduleID] if !ok || mod.TenantID != tenantID { return ErrNotFound } now := time.Now().UTC() mod.DeletedAt = &now return nil } func (m *Memory) moduleWriteOK(tenantID, moduleID string) (*Module, error) { mod, ok := m.modules[moduleID] if !ok || mod.DeletedAt != nil || mod.TenantID != tenantID { return nil, ErrNotFound } return mod, nil } func (m *Memory) ListCDNSources(tenantID, moduleID string) ([]*CDNSource, error) { m.mu.RLock() defer m.mu.RUnlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "CDN_CIDRS" { return nil, ErrInvalidInput } var out []*CDNSource for _, s := range m.cdnSources { if s.ModuleID == moduleID { out = append(out, s) } } return out, nil } func (m *Memory) CreateCDNSource(tenantID, moduleID string, in *CDNSource) (*CDNSource, error) { if in == nil || strings.TrimSpace(in.URL) == "" { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "CDN_CIDRS" { return nil, ErrInvalidInput } id := uuid.NewString() s := &CDNSource{ ID: id, ModuleID: moduleID, SourceKind: in.SourceKind, URL: strings.TrimSpace(in.URL), PrefixPath: strings.TrimSpace(in.PrefixPath), Etag: in.Etag, RefreshIntervalSec: in.RefreshIntervalSec, CommunityID: in.CommunityID, LastRefreshedAt: in.LastRefreshedAt, } m.cdnSources[id] = s mod.InputHash = "" return s, nil } func (m *Memory) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDNSourcePatch) (*CDNSource, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } s, ok := m.cdnSources[sourceID] if !ok || s.ModuleID != moduleID { return nil, ErrNotFound } if patch.SourceKind != nil { s.SourceKind = *patch.SourceKind } if patch.URL != nil { s.URL = strings.TrimSpace(*patch.URL) } if patch.PrefixPath != nil { s.PrefixPath = strings.TrimSpace(*patch.PrefixPath) } if patch.Etag != nil { s.Etag = *patch.Etag } if patch.RefreshIntervalSec != nil { s.RefreshIntervalSec = patch.RefreshIntervalSec } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { s.CommunityID = nil } else { s.CommunityID = &v } } if patch.LastRefreshedAt != nil { t := patch.LastRefreshedAt.UTC() s.LastRefreshedAt = &t } if CDNPatchAffectsInputHash(patch) { mod.InputHash = "" } return s, nil } func (m *Memory) DeleteCDNSource(tenantID, moduleID, sourceID string) error { m.mu.Lock() defer m.mu.Unlock() if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil { return err } s, ok := m.cdnSources[sourceID] if !ok || s.ModuleID != moduleID { return ErrNotFound } delete(m.cdnSources, sourceID) m.clearModuleInputHashLocked(moduleID) return nil } func (m *Memory) ListASEntries(tenantID, moduleID string) ([]*ASEntry, error) { m.mu.RLock() defer m.mu.RUnlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "AS_PREFIXES" { return nil, ErrInvalidInput } var out []*ASEntry for _, e := range m.asEntries { if e.ModuleID == moduleID { out = append(out, e) } } return out, nil } func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error) { if in == nil || !ValidASN(in.ASN) { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "AS_PREFIXES" { return nil, ErrInvalidInput } id := uuid.NewString() e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, CommunityID: in.CommunityID} m.asEntries[id] = e mod.InputHash = "" return e, nil } func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntryPatch) (*ASEntry, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } e, ok := m.asEntries[entryID] if !ok || e.ModuleID != moduleID { return nil, ErrNotFound } prevASN := e.ASN if patch.ASN != nil { e.ASN = *patch.ASN } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { e.CommunityID = nil } else { e.CommunityID = &v } } if !ValidASN(e.ASN) { return nil, ErrInvalidInput } if patch.ASN != nil && e.ASN != prevASN { e.ASNName = "" e.PrefixCount = nil e.ASNResolvedAt = nil } mod.InputHash = "" return e, nil } func (m *Memory) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error { m.mu.Lock() defer m.mu.Unlock() if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil { return err } e, ok := m.asEntries[entryID] if !ok || e.ModuleID != moduleID { return ErrNotFound } e.ASNName = strings.TrimSpace(asnName) pc := prefixCount e.PrefixCount = &pc t := resolvedAt.UTC() e.ASNResolvedAt = &t return nil } func (m *Memory) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []ASEntryResolveMetaUpdate, resolvedAt time.Time) error { for _, u := range updates { if err := m.UpdateASEntryResolveMeta(tenantID, moduleID, u.EntryID, u.ASNName, u.PrefixCount, resolvedAt); err != nil { return err } } return nil } func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error { m.mu.Lock() defer m.mu.Unlock() if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil { return err } e, ok := m.asEntries[entryID] if !ok || e.ModuleID != moduleID { return ErrNotFound } delete(m.asEntries, entryID) m.clearModuleInputHashLocked(moduleID) return nil } func (m *Memory) ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error) { m.mu.RLock() defer m.mu.RUnlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "DOMAINS" { return nil, ErrInvalidInput } var out []*DomainEntry for _, e := range m.domainEnt { if e.ModuleID == moduleID { out = append(out, e) } } return out, nil } func (m *Memory) CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error) { if in == nil || strings.TrimSpace(in.FQDN) == "" { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "DOMAINS" { return nil, ErrInvalidInput } id := uuid.NewString() e := &DomainEntry{ID: id, ModuleID: moduleID, FQDN: strings.TrimSpace(in.FQDN), CommunityID: in.CommunityID} m.domainEnt[id] = e mod.InputHash = "" return e, nil } func (m *Memory) UpdateDomainEntry(tenantID, moduleID, entryID string, patch *DomainEntryPatch) (*DomainEntry, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } e, ok := m.domainEnt[entryID] if !ok || e.ModuleID != moduleID { return nil, ErrNotFound } if patch.FQDN != nil { e.FQDN = strings.TrimSpace(*patch.FQDN) } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { e.CommunityID = nil } else { e.CommunityID = &v } } mod.InputHash = "" return e, nil } func (m *Memory) DeleteDomainEntry(tenantID, moduleID, entryID string) error { m.mu.Lock() defer m.mu.Unlock() if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil { return err } e, ok := m.domainEnt[entryID] if !ok || e.ModuleID != moduleID { return ErrNotFound } delete(m.domainEnt, entryID) m.clearModuleInputHashLocked(moduleID) return nil } func (m *Memory) ListIPRangeEntries(tenantID, moduleID string) ([]*IPRangeEntry, error) { m.mu.RLock() defer m.mu.RUnlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "IP_RANGES" { return nil, ErrInvalidInput } var out []*IPRangeEntry for _, e := range m.ipRanges { if e.ModuleID == moduleID { out = append(out, e) } } return out, nil } func (m *Memory) CreateIPRangeEntry(tenantID, moduleID string, in *IPRangeEntry) (*IPRangeEntry, error) { if in == nil || strings.TrimSpace(in.Prefix) == "" { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } if mod.Type != "IP_RANGES" { return nil, ErrInvalidInput } id := uuid.NewString() e := &IPRangeEntry{ID: id, ModuleID: moduleID, Prefix: strings.TrimSpace(in.Prefix), CommunityID: in.CommunityID} m.ipRanges[id] = e mod.InputHash = "" return e, nil } func (m *Memory) UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch *IPRangePatch) (*IPRangeEntry, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() mod, err := m.moduleWriteOK(tenantID, moduleID) if err != nil { return nil, err } e, ok := m.ipRanges[entryID] if !ok || e.ModuleID != moduleID { return nil, ErrNotFound } if patch.Prefix != nil { e.Prefix = strings.TrimSpace(*patch.Prefix) } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) if v == "" { e.CommunityID = nil } else { e.CommunityID = &v } } mod.InputHash = "" return e, nil } func (m *Memory) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error { m.mu.Lock() defer m.mu.Unlock() if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil { return err } e, ok := m.ipRanges[entryID] if !ok || e.ModuleID != moduleID { return ErrNotFound } delete(m.ipRanges, entryID) m.clearModuleInputHashLocked(moduleID) return nil } func (m *Memory) ListDohProfiles(tenantID string) ([]*DohProfile, error) { m.mu.RLock() defer m.mu.RUnlock() var out []*DohProfile for _, p := range m.dohProfiles { if p.TenantID == tenantID { out = append(out, p) } } return out, nil } func (m *Memory) GetDohProfile(tenantID, id string) (*DohProfile, error) { m.mu.RLock() defer m.mu.RUnlock() p, ok := m.dohProfiles[id] if !ok || p.TenantID != tenantID { return nil, ErrNotFound } return p, nil } func (m *Memory) CreateDohProfile(tenantID string, in *DohProfile) (*DohProfile, error) { if in == nil || strings.TrimSpace(in.URL) == "" { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[tenantID]; !ok { return nil, ErrTenantScope } id := uuid.NewString() p := &DohProfile{ ID: id, TenantID: tenantID, Name: in.Name, URL: strings.TrimSpace(in.URL), TimeoutMs: in.TimeoutMs, SecretRef: in.SecretRef, } m.dohProfiles[id] = p return p, nil } func (m *Memory) UpdateDohProfile(tenantID, id string, patch *DohProfilePatch) (*DohProfile, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() p, ok := m.dohProfiles[id] if !ok || p.TenantID != tenantID { return nil, ErrNotFound } if patch.Name != nil { p.Name = *patch.Name } if patch.URL != nil { p.URL = strings.TrimSpace(*patch.URL) } if patch.TimeoutMs != nil { p.TimeoutMs = patch.TimeoutMs } if patch.SecretRef != nil { p.SecretRef = patch.SecretRef } m.clearTenantModuleHashesLocked(tenantID) return p, nil } func (m *Memory) DeleteDohProfile(tenantID, id string) error { m.mu.Lock() defer m.mu.Unlock() p, ok := m.dohProfiles[id] if !ok || p.TenantID != tenantID { return ErrNotFound } for _, mod := range m.modules { if mod == nil || mod.DeletedAt != nil { continue } for _, pid := range mod.EffectiveDohProfileIDs() { if pid == id { return ErrInvalidInput } } } delete(m.dohProfiles, id) return nil } func (m *Memory) ListCommunities(tenantID string) ([]*Community, error) { m.mu.RLock() defer m.mu.RUnlock() var out []*Community for _, c := range m.communities { if c.TenantID == tenantID { out = append(out, c) } } return out, nil } func (m *Memory) GetCommunity(tenantID, idOrKey string) (*Community, error) { m.mu.RLock() defer m.mu.RUnlock() key := strings.TrimSpace(idOrKey) if key == "" { return nil, ErrNotFound } if c, ok := m.communities[key]; ok && c.TenantID == tenantID { return c, nil } for _, c := range m.communities { if c.TenantID != tenantID { continue } if c.Community == key || c.Title == key { return c, nil } if strings.TrimSpace(c.Title) != "" && c.Community+" ยท "+c.Title == key { return c, nil } } return nil, ErrNotFound } func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]PrefixRow, string, bool, error) { commRow, err := m.GetCommunity(tenantID, communityID) if err != nil { return nil, "", false, err } resolvedID := commRow.ID if limit <= 0 { limit = 500 } if limit > 5000 { limit = 5000 } off := 0 if cursor != "" { if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { off = n } } m.mu.RLock() defer m.mu.RUnlock() latestByModule := map[string]*Revision{} for _, rev := range m.revisions { if rev.TenantID != tenantID || strings.TrimSpace(rev.ModuleID) == "" { continue } cur := latestByModule[rev.ModuleID] if cur == nil || rev.CreatedAt.After(cur.CreatedAt) { latestByModule[rev.ModuleID] = rev } } seen := map[string]struct{}{} var all []PrefixRow for _, rev := range latestByModule { for _, pr := range m.revPrefixes[rev.ID] { if pr.CommunityID == nil || *pr.CommunityID != resolvedID { continue } pfx := strings.TrimSpace(pr.Prefix) if pfx == "" { continue } if _, ok := seen[pfx]; ok { continue } seen[pfx] = struct{}{} all = append(all, PrefixRow{Prefix: pfx, CommunityID: &resolvedID, Source: pr.Source}) } } sort.Slice(all, func(i, j int) bool { return all[i].Prefix < all[j].Prefix }) if off > len(all) { return nil, "", false, nil } end := off + limit more := false next := "" if end < len(all) { more = true next = strconv.Itoa(end) all = all[off:end] } else { all = all[off:] } if len(all) == 0 { return nil, "", false, nil } return all, next, more, nil } func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) { if in == nil || strings.TrimSpace(in.Community) == "" { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[tenantID]; !ok { return nil, ErrTenantScope } id := uuid.NewString() vj := in.ValueJSON if strings.TrimSpace(vj) == "" { vj = "{}" } c := &Community{ID: id, TenantID: tenantID, Community: strings.TrimSpace(in.Community), Title: strings.TrimSpace(in.Title), ValueJSON: vj} m.communities[id] = c return c, nil } func (m *Memory) UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() c, ok := m.communities[id] if !ok || c.TenantID != tenantID { return nil, ErrNotFound } if patch.Community != nil { c.Community = strings.TrimSpace(*patch.Community) } if patch.Title != nil { c.Title = strings.TrimSpace(*patch.Title) } if patch.ValueJSON != nil { c.ValueJSON = *patch.ValueJSON } return c, nil } func (m *Memory) DeleteCommunity(tenantID, id string) error { m.mu.Lock() defer m.mu.Unlock() c, ok := m.communities[id] if !ok || c.TenantID != tenantID { return ErrNotFound } _ = c // weak check: modules default_community for _, mod := range m.modules { if mod.DefaultCommunityID != nil && *mod.DefaultCommunityID == id { return ErrInvalidInput } } delete(m.communities, id) return nil } func (m *Memory) GetPeer(tenantID, id string) (*BGPPeer, error) { m.mu.RLock() defer m.mu.RUnlock() p, ok := m.peers[id] if !ok || p.TenantID != tenantID { return nil, ErrNotFound } return p, nil } func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) { if in == nil || in.RemoteASN == 0 { return nil, ErrInvalidInput } neighbor, ok := NormalizePeerNeighborString(in.Neighbor) if !ok { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[tenantID]; !ok { return nil, ErrTenantScope } id := uuid.NewString() p := &BGPPeer{ ID: id, TenantID: tenantID, SpeakerID: in.SpeakerID, Name: in.Name, Neighbor: neighbor, RemoteASN: in.RemoteASN, Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState), SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON, CreatedByUserID: strings.TrimSpace(in.CreatedByUserID), } m.peers[id] = p return p, nil } func (m *Memory) UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() p, ok := m.peers[id] if !ok || p.TenantID != tenantID { return nil, ErrNotFound } if patch.Neighbor != nil { n, ok := NormalizePeerNeighborString(*patch.Neighbor) if !ok { return nil, ErrInvalidInput } p.Neighbor = n } if patch.RemoteASN != nil { p.RemoteASN = *patch.RemoteASN } if patch.SpeakerID != nil { v := strings.TrimSpace(*patch.SpeakerID) if v == "" { p.SpeakerID = nil } else { p.SpeakerID = &v } } if patch.Enabled != nil { p.Enabled = *patch.Enabled } if patch.Name != nil { p.Name = *patch.Name } if patch.SessionState != nil { p.SessionState = *patch.SessionState } if patch.PoliciesJSON != nil { p.PoliciesJSON = *patch.PoliciesJSON } return p, nil } func (m *Memory) DeletePeer(tenantID, id string) error { m.mu.Lock() defer m.mu.Unlock() p, ok := m.peers[id] if !ok || p.TenantID != tenantID { return ErrNotFound } delete(m.peers, id) _ = p return nil } func (m *Memory) CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error) { if in == nil || strings.TrimSpace(in.Role) == "" { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[tenantID]; !ok { return nil, ErrTenantScope } id := uuid.NewString() sp := &Speaker{ID: id, TenantID: tenantID, Role: strings.TrimSpace(in.Role), Endpoint: in.Endpoint, MetaJSON: in.MetaJSON} m.speakers[id] = sp return sp, nil } func (m *Memory) UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error) { if patch == nil { return nil, ErrInvalidInput } m.mu.Lock() defer m.mu.Unlock() sp, ok := m.speakers[id] if !ok || sp.TenantID != tenantID { return nil, ErrNotFound } if patch.Role != nil { sp.Role = strings.TrimSpace(*patch.Role) } if patch.Endpoint != nil { sp.Endpoint = *patch.Endpoint } if patch.MetaJSON != nil { sp.MetaJSON = *patch.MetaJSON } return sp, nil } func (m *Memory) DeleteSpeaker(tenantID, id string) error { m.mu.Lock() defer m.mu.Unlock() sp, ok := m.speakers[id] if !ok || sp.TenantID != tenantID { return ErrNotFound } delete(m.speakers, id) delete(m.publishedRevision, id) return nil } func (m *Memory) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]PrefixRow, string, bool) { if limit <= 0 { limit = 50 } afterID, off, useOffset := ParsePrefixPageCursor(cursor) m.mu.RLock() defer m.mu.RUnlock() if _, err := m.getRevisionLocked(tenantID, revisionID); err != nil { return nil, "", false } allRows := m.revPrefixes[revisionID] start := 0 if useOffset { start = off } else if afterID != nil { start = int(*afterID) + 1 } if start > len(allRows) { return nil, "", false } end := start + limit next := "" more := false if end > len(allRows) { end = len(allRows) } else { more = true next = FormatPrefixPageCursor(int64(end - 1)) } if start >= end { return nil, "", false } out := make([]PrefixRow, end-start) copy(out, allRows[start:end]) return out, next, more } func (m *Memory) ListGlobalSettings(tenantID string) (map[string]any, error) { m.mu.RLock() defer m.mu.RUnlock() if m.settings[tenantID] == nil { return map[string]any{}, nil } out := make(map[string]any, len(m.settings[tenantID])) for k, v := range m.settings[tenantID] { out[k] = v } return out, nil } func (m *Memory) PatchGlobalSettings(tenantID string, patch map[string]any) error { if patch == nil { return nil } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[tenantID]; !ok { return ErrTenantScope } if m.settings[tenantID] == nil { m.settings[tenantID] = make(map[string]any) } for key, val := range patch { if strings.TrimSpace(key) == "" { continue } if val == nil { delete(m.settings[tenantID], key) continue } m.settings[tenantID][key] = val } return nil }