package storage import ( "context" "database/sql" "errors" "fmt" "log" "sort" "strconv" "strings" "sync" "time" "github.com/metazla/meta-core/internal/kv" "github.com/metazla/meta-core/internal/schema" ) // Key layout (everything is one string→string table, see internal/kv): // // file:/ a record property — the ONLY thing under `file:` // cid: reverse index → // idx:roots/ the set of every root (was the Redis SET file:__index__) // idx:fts/ rowid of the record's row in the FTS table // idx:count/…, idx:schema/… derived counters (root count, per-field schema), see onChange // dup:/ a duplicate path of the record (was the SET file:/duplicates) // udl:… User Data Layer, see udl.go // ev:/…, evhwm:… event streams, see internal/kv/stream.go // // Keeping bookkeeping OUT of `file:` is load-bearing: the `file:` range is // exactly the records, so a record is the key range [file:/, file:0), // the field expression index covers only real fields, and every change under // `file:` is a real field change worth publishing on meta:events. const ( recordNS = "file:" rootsNS = "idx:roots/" ftsNS = "idx:fts/" dupNS = "dup:" streamNS = "ev:" hwmNS = "evhwm:" opTimeout = 30 * time.Second bulkBatch = 10_000 readerPage = 5_000 ) // MetaEventsStream receives a set/del entry for every record-key change. It is // written by the storage engine in the same transaction as the change (the // replacement for Redis keyspace notifications), and served as SSE by // /api/events/meta. const MetaEventsStream = "meta:events" // MetaEventsMaxLen is the approximate cap on meta:events. Bounded retention is // a contract requirement for the SSE wire — see docs/api-mediated-access.md // "Backing-store retention and lifecycle". const MetaEventsMaxLen = 100_000 // Client is meta-core's metadata store. type Client struct { store *kv.Store mu sync.RWMutex keysMu sync.Mutex keysCount int64 keysAt time.Time } // NewClient creates an unconnected client. func NewClient() *Client { return &Client{} } // Connect opens (creating if needed) the database file at path. func (c *Client) Connect(path string) error { c.mu.Lock() defer c.mu.Unlock() store, err := kv.Open(path, kv.Options{ EventStream: MetaEventsStream, EventMaxLen: MetaEventsMaxLen, EventKey: func(k string) bool { return strings.HasPrefix(k, recordNS) }, WriterCacheMB: envInt("META_CORE_DB_CACHE_MB", 64), ReaderCacheMB: envInt("META_CORE_DB_READER_CACHE_MB", 16), Readers: envInt("META_CORE_DB_READERS", 8), Schema: searchSchema, OnChange: onChange, }) if err != nil { return fmt.Errorf("open %s: %w", path, err) } c.store = store log.Printf("[Storage] Opened %s", path) return nil } // Close closes the database. func (c *Client) Close() error { c.mu.Lock() defer c.mu.Unlock() if c.store == nil { return nil } err := c.store.Close() c.store = nil return err } // IsConnected reports whether the database is open. func (c *Client) IsConnected() bool { c.mu.RLock() defer c.mu.RUnlock() return c.store != nil } // Health checks that the database answers. func (c *Client) Health() bool { s, err := c.kv() if err != nil { return false } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() return s.Ping(ctx) == nil } // Store exposes the underlying engine (event streams for SSE and the schema // indexer). func (c *Client) Store() *kv.Store { c.mu.RLock() defer c.mu.RUnlock() return c.store } func (c *Client) kv() (*kv.Store, error) { c.mu.RLock() defer c.mu.RUnlock() if c.store == nil { return nil, fmt.Errorf("not connected") } return c.store, nil } // update runs fn in one write transaction with the standard timeout. func (c *Client) update(fn func(tx *kv.Tx) error) error { s, err := c.kv() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), opTimeout) defer cancel() return s.Update(ctx, fn) } func opCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), opTimeout) } func recordPrefix(hashID string) string { return recordNS + hashID + "/" } func rootKey(hashID string) string { return rootsNS + hashID } func dupPrefix(hashID string) string { return dupNS + hashID + "/" } // isReservedFieldName reports whether field names bookkeeping rather than // metadata. `__fields__` was the Redis-era per-record field index; it no // longer exists, but a payload carrying it (an old snapshot) is still refused // so it can never become a phantom field. func isReservedFieldName(field string) bool { return field == "__fields__" } // splitRecordKey decomposes a key into (hashID, field) when it is a record // property key — `file:/`, split at the FIRST slash so CID-rooted // and UUID-rooted records parse alike. func splitRecordKey(key string) (hashID, field string, ok bool) { rest, found := strings.CutPrefix(key, recordNS) if !found { return "", "", false } hashID, field, found = strings.Cut(rest, "/") if !found || hashID == "" || field == "" || isReservedFieldName(field) { return "", "", false } return hashID, field, true } // isInternalKey reports keys that are derived bookkeeping, which the raw KV // editor must never write (it would desynchronise the derived state). func isInternalKey(key string) bool { return strings.HasPrefix(key, "idx:") || strings.HasPrefix(key, dupNS) || strings.HasPrefix(key, streamNS) || strings.HasPrefix(key, hwmNS) } // Derived counters, maintained by onChange in the same transaction as the // change they count (kv.Tx.AddCounter): the root count behind search's `total` // and /api/stats, and the per-field schema behind /api/schema. const ( rootsCountKey = "idx:count/roots" countersReady = "idx:count/ready" counterRebuild = 5000 ) // onChange keeps the derived counters exact. It sees only real changes (kv // skips unchanged Puts) with the old and new values, so a modified field moves // one count from its old label to its new one. func onChange(tx *kv.Tx, k, old string, hadOld bool, v string, hasNew bool) { if strings.HasPrefix(k, rootsNS) { switch { case hasNew && !hadOld: tx.AddCounter(rootsCountKey, 1) case hadOld && !hasNew: tx.AddCounter(rootsCountKey, -1) } return } if !strings.HasPrefix(k, recordNS) { return } if hadOld { if ck, ok := schema.CounterKey(k, old); ok { tx.AddCounter(ck, -1) } } if hasNew { if ck, ok := schema.CounterKey(k, v); ok { tx.AddCounter(ck, 1) } } } // EnsureCounters builds the derived counters when they have never been built — // a store created before they existed, or one restored from outside meta-core. // Blocking and single-transaction (writes wait for it; ~1–2 min at millions of // keys, once), because a build racing live deltas would double-count. func (c *Client) EnsureCounters() (bool, error) { s, err := c.kv() if err != nil { return false, err } ctx, cancel := opCtx() _, ready, err := s.Get(ctx, countersReady) cancel() if err != nil || ready { return false, err } return true, c.RebuildCounters() } // RebuildCounters recomputes every derived counter from the records, in one // transaction (writers wait for it). func (c *Client) RebuildCounters() error { s, err := c.kv() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() return s.Update(ctx, func(tx *kv.Tx) error { for _, p := range []string{schema.CounterPrefix, rootsCountKey} { if _, err := tx.DeletePrefix(p, 0); err != nil { return err } } roots, err := tx.Count(tx.Context(), rootsNS) if err != nil { return err } tx.AddCounter(rootsCountKey, roots) after := "" for { rows, err := tx.Range(tx.Context(), recordNS, after, counterRebuild) if err != nil { return err } for _, row := range rows { if ck, ok := schema.CounterKey(row.K, row.V); ok { tx.AddCounter(ck, 1) } } if len(rows) < counterRebuild { break } after = rows[len(rows)-1].K } return tx.Put(countersReady, "1") }) } // readRecord returns a record's flat fields (nil when it has none). func readRecord(ctx context.Context, r kv.Reader, hashID string) (map[string]string, error) { prefix := recordPrefix(hashID) rows, err := r.Range(ctx, prefix, "", 0) if err != nil { return nil, fmt.Errorf("read %s: %w", hashID, err) } if len(rows) == 0 { return nil, nil } out := make(map[string]string, len(rows)) for _, row := range rows { out[row.K[len(prefix):]] = row.V } return out, nil } // writeFieldsTx is the one write path for record fields: the keys, the root // index, the CID reverse aliases and the search row, all in the caller's // transaction. func writeFieldsTx(tx *kv.Tx, hashID string, metadata map[string]string) error { if hashID == "" { return fmt.Errorf("empty hash id") } prefix := recordPrefix(hashID) searchChanged := false for field, value := range metadata { if isReservedFieldName(field) { continue } changed, err := tx.Set(prefix+field, value) if err != nil { return err } searchChanged = searchChanged || (changed && affectsHaystack(field)) } newRoot, err := tx.Set(rootKey(hashID), "") if err != nil { return err } for field := range metadata { if cidStr := cidFromKeysetField(field); cidStr != "" { if err := addAliasTx(tx, hashID, cidStr); err != nil { return err } } } // The FTS row is rewritten only when the haystack can have changed — a // re-persisted record whose values are all in place costs reads only. if searchChanged || newRoot { return refreshSearchTx(tx, hashID) } return nil } // GetMetadataFlat retrieves all metadata for a file as a flat map. func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) { s, err := c.kv() if err != nil { return nil, err } ctx, cancel := opCtx() defer cancel() return readRecord(ctx, s, hashID) } // SetMetadataFlat stores metadata for a file using flat keys (merge: fields // absent from metadata are kept — the Redis implementation never cleared // either). func (c *Client) SetMetadataFlat(hashID string, metadata map[string]string) error { if len(metadata) == 0 { return nil } return c.update(func(tx *kv.Tx) error { return writeFieldsTx(tx, hashID, metadata) }) } // MergeMetadataFlat merges new metadata into existing (PATCH semantics): new // keys are added, existing keys are updated, missing keys are NOT deleted. func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (int, error) { if len(metadata) == 0 { return 0, nil } if err := c.update(func(tx *kv.Tx) error { return writeFieldsTx(tx, hashID, metadata) }); err != nil { return 0, err } return len(metadata), nil } // GetAllHashIDs returns every root id, sorted. func (c *Client) GetAllHashIDs() ([]string, error) { s, err := c.kv() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 2*opTimeout) defer cancel() var out []string err = s.ScanPrefix(ctx, rootsNS, readerPage, func(rows []kv.KV) error { for _, row := range rows { out = append(out, row.K[len(rootsNS):]) } return nil }) return out, err } // GetProperty retrieves a single property value ("" when absent). func (c *Client) GetProperty(hashID, property string) (string, error) { s, err := c.kv() if err != nil { return "", err } ctx, cancel := opCtx() defer cancel() v, _, err := s.Get(ctx, recordPrefix(hashID)+property) return v, err } // SetProperty sets a single property value. func (c *Client) SetProperty(hashID, property, value string) error { if isReservedFieldName(property) { return fmt.Errorf("field name %q is reserved", property) } return c.update(func(tx *kv.Tx) error { return writeFieldsTx(tx, hashID, map[string]string{property: value}) }) } // deleteRecordTx removes everything keyed off hashID: its properties, the // reverse-index entries its cids/ members own, its duplicates, its root-index // entry and its search row. Returns the number of property keys removed. func deleteRecordTx(tx *kv.Tx, hashID string) (int64, error) { ctx := tx.Context() cids, err := cidsForRoot(ctx, tx, hashID) if err != nil { return 0, err } for _, cidStr := range cids { if _, err := tx.Delete(CIDIndexPrefix + cidStr); err != nil { return 0, err } } n, err := tx.DeletePrefix(recordPrefix(hashID), 0) if err != nil { return 0, err } if _, err := tx.DeletePrefix(dupPrefix(hashID), 0); err != nil { return n, err } if _, err := tx.Delete(rootKey(hashID)); err != nil { return n, err } return n, deleteSearchRowTx(tx, hashID) } // DeleteMetadata deletes all metadata for a file, and unmaps every // reverse-index entry registered for this root, so a subsequent // GET /api/meta/ correctly returns 404 instead of pointing at a root // with no keys. func (c *Client) DeleteMetadata(hashID string) (int64, error) { var n int64 err := c.update(func(tx *kv.Tx) error { var err error n, err = deleteRecordTx(tx, hashID) return err }) return n, err } // CountFiles returns the number of indexed roots. func (c *Client) CountFiles() (int, error) { n, err := c.CountRoots() return int(n), err } // LookupPathByCID resolves a bare CID to its file path on disk via the // reverse index: one lookup followed by a single read of file:/filePath. // // Any registered CID resolves — the midhash, a sibling sha2-256, an IPFS // CID, a btih, etc. (they all alias to the same root). The bare CID is what // the editor passes to /api/file/{cid}/info and what plugins write (e.g. // `backdrop=`). // // Plugin output (TMDB posters/backdrops, extracted subtitles, etc.) is // resolved through this same path: the plugin output directory is a // watcher root (see config.DefaultWatcherPaths), so every plugin artefact // gets a midhash alias and resolves through the reverse index like any // other file. There is no path-companion sidecar field. func (c *Client) LookupPathByCID(cidStr string) (string, error) { uuid, err := c.GetByCID(cidStr) if err != nil { return "", err } if uuid == "" { return "", nil } fp, err := c.GetProperty(uuid, "filePath") if err != nil { return "", err } return normalizeFilesRelativePath(fp), nil } // normalizeFilesRelativePath strips a leading "/files/" prefix so handlers can // safely filepath.Join(FilesPath, …) without double-prefixing. Different // plugins store paths inconsistently — poster/backdrop use FilesPath-relative // ("/plugin/…") while filePath is FilesPath-absolute ("/files/watch/…"). func normalizeFilesRelativePath(p string) string { return strings.TrimPrefix(p, "/files") } // DeleteProperty deletes a single property. // // For a cids/ member this is the full inverse of the write: the flat key // AND the cid: reverse alias both go. Anything less leaves the CID still // resolving to this record — see removeAliasTx. func (c *Client) DeleteProperty(hashID, property string) error { return c.update(func(tx *kv.Tx) error { if _, err := tx.Delete(recordPrefix(hashID) + property); err != nil { return err } if cidStr := cidFromKeysetField(property); cidStr != "" { if err := removeAliasTx(tx, hashID, cidStr); err != nil { return fmt.Errorf("remove alias for %s: %w", cidStr, err) } } if affectsHaystack(property) { return refreshSearchTx(tx, hashID) } return nil }) } // AddToSet adds a value to a set-type field (stored as a pipe-delimited string // in one flat key). Returns true if value was added, false if it already // existed. func (c *Client) AddToSet(hashID, property, value string) (bool, error) { added := false err := c.update(func(tx *kv.Tx) error { current, _, err := tx.Get(tx.Context(), recordPrefix(hashID)+property) if err != nil { return err } var values []string if current != "" { values = strings.Split(current, "|") } for _, v := range values { if v == value { return nil } } added = true return writeFieldsTx(tx, hashID, map[string]string{property: strings.Join(append(values, value), "|")}) }) return added, err } // RemoveFromSet removes a value from a set-type field (stored in one flat key). // Returns true if value was removed, false if it didn't exist. func (c *Client) RemoveFromSet(hashID, property, value string) (bool, error) { removed := false err := c.update(func(tx *kv.Tx) error { key := recordPrefix(hashID) + property current, ok, err := tx.Get(tx.Context(), key) if err != nil || !ok { return err } values := strings.Split(current, "|") kept := make([]string, 0, len(values)) for _, v := range values { if v == value { removed = true } else { kept = append(kept, v) } } if !removed { return nil } if len(kept) == 0 { if _, err := tx.Delete(key); err != nil { return err } if affectsHaystack(property) { return refreshSearchTx(tx, hashID) } return nil } return writeFieldsTx(tx, hashID, map[string]string{property: strings.Join(kept, "|")}) }) return removed, err } // FileTuple holds the (uuid, path, size, mtime) record for one file. Used by // the watcher hydrator at startup to avoid re-mid-hashing files whose tuple // matches what's already stored. type FileTuple struct { HashID string FilePath string Size int64 MtimeNano int64 } // GetTuplesForAllFiles returns one FileTuple per indexed root, in batched // multi-key reads. Skips files missing any of filePath, sizeByte, mtimeNano. func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) { s, err := c.kv() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 2*opTimeout) defer cancel() fields := [3]string{"filePath", "sizeByte", "mtimeNano"} var out []FileTuple err = s.ScanPrefix(ctx, rootsNS, 1000, func(rows []kv.KV) error { keys := make([]string, 0, len(rows)*len(fields)) for _, row := range rows { prefix := recordPrefix(row.K[len(rootsNS):]) for _, f := range fields { keys = append(keys, prefix+f) } } vals, err := s.MGet(ctx, keys) if err != nil { return err } for _, row := range rows { h := row.K[len(rootsNS):] prefix := recordPrefix(h) filePath, sizeStr, mtimeStr := vals[prefix+fields[0]], vals[prefix+fields[1]], vals[prefix+fields[2]] if filePath == "" || sizeStr == "" || mtimeStr == "" { continue } size, err := strconv.ParseInt(sizeStr, 10, 64) if err != nil { continue } mtime, err := strconv.ParseInt(mtimeStr, 10, 64) if err != nil { continue } out = append(out, FileTuple{HashID: h, FilePath: filePath, Size: size, MtimeNano: mtime}) } return nil }) return out, err } // GetMemoryInfo reports the store's footprint for dashboards: the database // file plus its WAL, human-readable. (Under Redis this was used_memory_human; // the data no longer lives in RAM, so the disk footprint is the honest figure.) func (c *Client) GetMemoryInfo() (string, error) { s, err := c.kv() if err != nil { return "", err } return humanBytes(s.FileSize()), nil } // DBSize is the database file plus WAL, in bytes. func (c *Client) DBSize() int64 { s, err := c.kv() if err != nil { return 0 } return s.FileSize() } func humanBytes(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%dB", n) } div, exp := int64(unit), 0 for m := n / unit; m >= unit; m /= unit { div *= unit exp++ } return fmt.Sprintf("%.2f%c", float64(n)/float64(div), "KMGTPE"[exp]) } // ClearAllMetadata deletes all file metadata, the root index, the CID reverse // index, duplicates and search rows. Returns the number of roots deleted. // // The CID reverse index MUST be purged too: ResolveRoot consults it, so a // surviving cid: entry pointing at a root we just deleted silently // redirects the next /meta/ write back into that dead root, resurrecting // a zombie record under an old identity. // // Runs as many bounded transactions rather than one: a single transaction over // millions of rows would hold the writer for minutes and balloon the WAL. func (c *Client) ClearAllMetadata() (int64, error) { roots, err := c.CountRoots() if err != nil { return 0, err } for _, prefix := range []string{recordNS, CIDIndexPrefix, dupNS, rootsNS, ftsNS} { for { var n int64 if err := c.update(func(tx *kv.Tx) error { var err error n, err = tx.DeletePrefix(prefix, bulkBatch) return err }); err != nil { return 0, fmt.Errorf("clear %s: %w", prefix, err) } if n < bulkBatch { break } } } if err := c.update(func(tx *kv.Tx) error { _, err := tx.Exec(`DELETE FROM fts`) return err }); err != nil { return 0, fmt.Errorf("clear search rows: %w", err) } log.Printf("[Storage] Cleared %d file metadata entries", roots) return roots, nil } // ScanKeys lists the immediate children at the given prefix, S3-style. // // When delimiter is set, keys with a delimiter occurrence after the prefix // collapse into a branch path (the prefix up to and including that first // delimiter); the rest are returned as leaves. The walk jumps over each // branch's whole key range once seen, so listing `file:` costs one read per // record, not one per key. Stops when leaves+branches reaches maxResults; the // returned cursor (the next key to examine, "0" when exhausted) continues it. func (c *Client) ScanKeys(prefix, delimiter, cursorStr string, maxResults int) ([]string, []string, string, error) { s, err := c.kv() if err != nil { return nil, nil, "", err } if maxResults <= 0 { maxResults = 500 } from := prefix if cursorStr != "" && cursorStr != "0" && cursorStr > prefix { from = cursorStr } end := kv.PrefixEnd(prefix) ctx, cancel := opCtx() defer cancel() var leaves, branches []string next := "0" op := ">=" // after a leaf the bound becomes exclusive (k > leaf) for { query := `SELECT k FROM kv WHERE k ` + op + ` ?` args := []any{from} if end != "" { query += ` AND k < ?` args = append(args, end) } var k string err := s.DB().QueryRowContext(ctx, query+` ORDER BY k LIMIT 1`, args...).Scan(&k) if errors.Is(err, sql.ErrNoRows) { break } if err != nil { return nil, nil, "", err } if len(leaves)+len(branches) >= maxResults { next = k break } suffix := k[len(prefix):] if delimiter != "" { if idx := strings.Index(suffix, delimiter); idx >= 0 { branch := prefix + suffix[:idx+len(delimiter)] branches = append(branches, branch) if from = kv.PrefixEnd(branch); from == "" { break } op = ">=" continue } } leaves = append(leaves, k) from, op = k, ">" } sort.Strings(leaves) sort.Strings(branches) return leaves, branches, next, nil } // FindMatch is a single value-search hit. type FindMatch struct { Key string `json:"key"` Value string `json:"value"` EntryPath string `json:"entryPath"` Field string `json:"field"` } // FindByValue returns keys whose path ends with / (for each field in // fields) and whose value contains the substring, case-insensitively. // truncated is true when limit was hit. // // EntryPath is the parent prefix up to (and including) the slash before // field — i.e. the file branch path. Useful for revealing the matched entry // in the tree. func (c *Client) FindByValue(contains string, fields []string, limit int) ([]FindMatch, bool, error) { s, err := c.kv() if err != nil { return nil, false, err } if contains == "" || len(fields) == 0 { return nil, false, nil } if limit <= 0 { limit = 200 } ctx, cancel := context.WithTimeout(context.Background(), 2*opTimeout) defer cancel() needle := strings.ToLower(contains) var matches []FindMatch truncated := false for _, field := range fields { field = strings.TrimSpace(field) if field == "" || isReservedFieldName(field) { continue } if len(matches) >= limit { truncated = true break } suffix := "/" + field // Suffix match over every key (records and non-record keys alike, as // the SCAN "*/" it replaces did). Value containment is checked in // Go: SQLite's lower() folds ASCII only. rows, err := s.DB().QueryContext(ctx, `SELECT k, v FROM kv WHERE substr(k, -?) = ? AND instr(lower(v), ?) > 0 ORDER BY k`, len(suffix), suffix, asciiLower(needle)) if err != nil { return nil, false, err } for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { rows.Close() return nil, false, err } if isInternalKey(k) || !strings.Contains(strings.ToLower(v), needle) { continue } matches = append(matches, FindMatch{Key: k, Value: v, EntryPath: strings.TrimSuffix(k, field), Field: field}) if len(matches) >= limit { truncated = true break } } rows.Close() } sort.Slice(matches, func(i, j int) bool { return matches[i].Key < matches[j].Key }) return matches, truncated, nil } // SearchKeys returns up to limit keys whose full path contains the substring, // in key order. truncated is true when the search stopped at limit. func (c *Client) SearchKeys(contains string, limit int) ([]string, bool, error) { s, err := c.kv() if err != nil { return nil, false, err } if contains == "" { return nil, false, nil } if limit <= 0 { limit = 500 } ctx, cancel := opCtx() defer cancel() rows, err := s.DB().QueryContext(ctx, `SELECT k FROM kv WHERE instr(k, ?) > 0 ORDER BY k LIMIT ?`, contains, limit+1) if err != nil { return nil, false, err } defer rows.Close() results := make([]string, 0, limit) for rows.Next() { var k string if err := rows.Scan(&k); err != nil { return nil, false, err } results = append(results, k) } if err := rows.Err(); err != nil { return nil, false, err } if len(results) > limit { return results[:limit], true, nil } return results, false, nil } // GetKeyInfo returns the key's type — "string" when it exists (every row is a // string now), with its value. exists is false when the key is absent. func (c *Client) GetKeyInfo(key string) (kind string, value string, exists bool, err error) { s, err := c.kv() if err != nil { return "", "", false, err } ctx, cancel := opCtx() defer cancel() v, ok, err := s.Get(ctx, key) if err != nil || !ok { return "", "", false, err } return "string", v, true, nil } // SetRawValue writes a string value at the exact key (the raw KV editor). // Refuses derived bookkeeping keys. A record key keeps the search row honest // like every other write path. func (c *Client) SetRawValue(key, value string) error { if isInternalKey(key) { return fmt.Errorf("key %q is internal bookkeeping and not editable", key) } return c.update(func(tx *kv.Tx) error { if err := tx.Put(key, value); err != nil { return err } if hashID, field, ok := splitRecordKey(key); ok && affectsHaystack(field) { return refreshSearchTx(tx, hashID) } return nil }) } // DeleteRawKey deletes the exact key. Returns true if the key existed. func (c *Client) DeleteRawKey(key string) (bool, error) { if isInternalKey(key) { return false, fmt.Errorf("key %q is internal bookkeeping and not editable", key) } existed := false err := c.update(func(tx *kv.Tx) error { var err error if existed, err = tx.Delete(key); err != nil || !existed { return err } if hashID, field, ok := splitRecordKey(key); ok && affectsHaystack(field) { return refreshSearchTx(tx, hashID) } return nil }) return existed, err } // IndexAdd adds a hash ID to the root index. Idempotent. func (c *Client) IndexAdd(hashID string) error { return c.update(func(tx *kv.Tx) error { if err := tx.Put(rootKey(hashID), ""); err != nil { return err } return ensureSearchRowTx(tx, hashID) }) } // IndexRemoveIfEmpty removes hashID from the root index (and drops its search // row) when no file:/* keys remain. Returns true if it was removed. func (c *Client) IndexRemoveIfEmpty(hashID string) (bool, error) { removed := false err := c.update(func(tx *kv.Tx) error { n, err := tx.Count(tx.Context(), recordPrefix(hashID)) if err != nil || n > 0 { return err } if removed, err = tx.Delete(rootKey(hashID)); err != nil { return err } return deleteSearchRowTx(tx, hashID) }) return removed, err } // GetPrefix returns the key namespace prefix. Always empty: meta-core owns its // database file outright, so there is nothing to share a keyspace with. func (c *Client) GetPrefix() string { return "" } // XAdd appends an event to a stream. maxLen > 0 trims to about that many // entries; maxLen <= 0 keeps everything. func (c *Client) XAdd(stream string, maxLen int64, fields map[string]interface{}) (string, error) { values := make(map[string]string, len(fields)) for k, v := range fields { values[k] = fmt.Sprint(v) } var id string err := c.update(func(tx *kv.Tx) error { var err error id, err = tx.Append(stream, maxLen, values) return err }) return id, err } // AppendEvents appends a batch of entries to a stream in one transaction. func (c *Client) AppendEvents(stream string, maxLen int64, entries []map[string]string) error { if len(entries) == 0 { return nil } return c.update(func(tx *kv.Tx) error { for _, e := range entries { if _, err := tx.Append(stream, 0, e); err != nil { return err } } if maxLen > 0 { return tx.TrimAll(stream, maxLen) } return nil }) } // ClearStream deletes every entry of a stream (its ID high-water mark // survives, so IDs keep increasing across the clear). func (c *Client) ClearStream(stream string) error { return c.update(func(tx *kv.Tx) error { return tx.ClearStream(stream) }) } // StreamEntry is one stream entry. type StreamEntry struct { ID string Values map[string]interface{} } // XRevRange reads stream entries from newest to oldest. start must be "+" or // empty (newest); stop is "-"/empty (oldest) or "(" for an exclusive lower // bound — the only shapes callers use. count > 0 caps the result. func (c *Client) XRevRange(stream, start, stop string, count int64) ([]StreamEntry, error) { s, err := c.kv() if err != nil { return nil, err } if start != "" && start != "+" { return nil, fmt.Errorf("XRevRange: unsupported start %q", start) } after := "" switch { case stop == "" || stop == "-": case strings.HasPrefix(stop, "("): after = stop[1:] default: return nil, fmt.Errorf("XRevRange: unsupported stop %q", stop) } ctx, cancel := opCtx() defer cancel() entries, err := s.RevRange(ctx, stream, after, int(count)) if err != nil { return nil, err } out := make([]StreamEntry, 0, len(entries)) for _, e := range entries { values := make(map[string]interface{}, len(e.Values)) for k, v := range e.Values { values[k] = v } out = append(out, StreamEntry{ID: e.ID, Values: values}) } return out, nil } // GetStreamLength returns the number of surviving entries of a stream. func (c *Client) GetStreamLength(stream string) (int64, error) { s, err := c.kv() if err != nil { return 0, err } ctx, cancel := opCtx() defer cancel() return s.Len(ctx, stream) }