mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Refunds and disputes were recorded under an in-process mutex, and the renewal guard was a read then a write under another. Two processes over one store, or a webhook beside the billing cycle, could each read the invoice and each write it back, the second wiping the first's refund. A swap writes only while the stored entity's updatedAt is still the one read (insert-if-absent for a new or deleted entity) and otherwise reads again: db.Swapper on SQLite and Postgres as one statement, datastore.Swap, mixin.Model.Swap. Refund and dispute recording swap the invoice or payment record in, and idempotencykey.Begin swaps its first sighting and its stale re-claim, so a guarded money move runs once across replicas. The returns and lockPeriod mutexes are gone. lockFunds stays: api/transaction mounts only in standalone commerce, whose store has one writer. The tests open one store file through two handles, each with its own write lock: 24 refunds delivered twice to both, with dispute reports between them, land once each; 32 sightings of one key proceed once. A blind write in the refund recorder fails the first test every run.
738 lines
25 KiB
Go
738 lines
25 KiB
Go
// Package db provides a multi-layer database abstraction supporting:
|
|
// - User-level SQLite with sqlite-vec for personal data and vector search
|
|
// - Organization-level SQLite for shared tenant data
|
|
// - Hanzo Datastore (DATASTORE_URL) for deep analytics and parallel queries
|
|
//
|
|
// Architecture:
|
|
//
|
|
// ┌─────────────────────────────────────────────────────────────┐
|
|
// │ Query Layer │
|
|
// ├─────────────────────────────────────────────────────────────┤
|
|
// │ User SQLite │ Org SQLite │ Hanzo Datastore │
|
|
// │ (per-user) │ (per-org) │ (DATASTORE_URL) │
|
|
// │ + sqlite-vec │ + sqlite-vec │ (parallel queries) │
|
|
// │ Fast queries │ Shared data │ Deep analytics │
|
|
// └─────────────────────────────────────────────────────────────┘
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/namespace"
|
|
ormdb "github.com/hanzoai/orm/db"
|
|
)
|
|
|
|
// Tenant types. They name the two keyspaces the registry keeps apart — a user
|
|
// and an org may share an id and are still different tenants — and they are the
|
|
// TenantType every store reports.
|
|
const (
|
|
tenantUser = "user"
|
|
tenantOrg = "org"
|
|
)
|
|
|
|
const (
|
|
// defaultMaxOpenTenants bounds how many tenant stores stay open. The bound
|
|
// is what makes this safe on a node serving many tenants: without one, an
|
|
// open-per-tenant cache is a file descriptor leak with extra steps.
|
|
defaultMaxOpenTenants = 256
|
|
|
|
// defaultTenantIdleTTL closes stores nobody has touched for this long, so a
|
|
// node that goes quiet hands its descriptors back instead of holding the
|
|
// high-water mark until restart.
|
|
defaultTenantIdleTTL = 5 * time.Minute
|
|
)
|
|
|
|
var (
|
|
// ErrNoSuchEntity is returned when an entity is not found
|
|
ErrNoSuchEntity = errors.New("db: no such entity")
|
|
|
|
// ErrInvalidKey is returned when a key is invalid
|
|
ErrInvalidKey = errors.New("db: invalid key")
|
|
|
|
// ErrInvalidEntityType is returned when an entity type is invalid
|
|
ErrInvalidEntityType = errors.New("db: invalid entity type")
|
|
|
|
// ErrConcurrentModification is returned when optimistic locking fails
|
|
ErrConcurrentModification = errors.New("db: concurrent modification")
|
|
|
|
// ErrDatabaseClosed is returned when operating on a closed database
|
|
ErrDatabaseClosed = errors.New("db: database closed")
|
|
)
|
|
|
|
// Layer represents which database layer to use
|
|
type Layer int
|
|
|
|
const (
|
|
// LayerUser uses the user-specific SQLite database
|
|
LayerUser Layer = iota
|
|
|
|
// LayerOrg uses the organization-level SQLite database
|
|
LayerOrg
|
|
|
|
// LayerDatastore uses the Hanzo Datastore (Datastore) for analytics
|
|
LayerDatastore
|
|
|
|
// LayerAll queries all layers (for cross-cutting queries)
|
|
LayerAll
|
|
)
|
|
|
|
// Config holds database configuration options
|
|
type Config struct {
|
|
// DataDir is the base directory for data storage
|
|
DataDir string
|
|
|
|
// UserDataDir is the directory for per-user SQLite databases.
|
|
// Defaults to DataDir/users.
|
|
//
|
|
// There is no OrgDataDir counterpart: an org's file is placed by
|
|
// hanzoai/namespace under DataDir (namespace.Path → DataDir/orgs/<slug>/…),
|
|
// which is the ONE layout every Hanzo service renders. This field survives
|
|
// only because namespace has no layout for a user, so the per-user tree stays
|
|
// commerce's own — see tenantNamespace in encryption.go.
|
|
UserDataDir string
|
|
|
|
// MasterKey is the 32-byte at-rest encryption master key, supplied by the
|
|
// EMBEDDER. When non-nil it is used verbatim and COMMERCE_KMS_MASTER_KEY is
|
|
// never read.
|
|
//
|
|
// It exists so a host that already holds a master key does not have to mint a
|
|
// SECOND one and hand it over through the environment. cloud embeds commerce
|
|
// in its own process and already resolves a 32-byte KEK
|
|
// (CLOUD_KMS_MASTER_KEY_REF); making commerce reach independently for an env
|
|
// var meant two keys, two KMS paths and two sync objects for one process —
|
|
// and when the second was never provisioned, commerce refused to boot, Mount
|
|
// never published its handler, and every balance read in the fleet failed.
|
|
// The embedder passing what it already has is one key, one source, and a
|
|
// dependency the type system can see.
|
|
//
|
|
// nil ⇒ unchanged: ResolveMasterKey reads the env, which remains the
|
|
// standalone deployment's path.
|
|
MasterKey []byte
|
|
|
|
// MaxOpenTenants bounds how many per-tenant stores are open at once; the
|
|
// least recently used idle one is closed to stay under it.
|
|
// Defaults to defaultMaxOpenTenants.
|
|
MaxOpenTenants int
|
|
|
|
// TenantIdleTTL closes a per-tenant store untouched for this long, even
|
|
// below MaxOpenTenants. Defaults to defaultTenantIdleTTL; negative disables.
|
|
TenantIdleTTL time.Duration
|
|
|
|
// DatastoreDSN is the connection string for Hanzo Datastore (DATASTORE_URL)
|
|
DatastoreDSN string
|
|
|
|
// EnableDatastore enables the Hanzo Datastore layer
|
|
EnableDatastore bool
|
|
|
|
// EnableVectorSearch enables sqlite-vec for vector embeddings
|
|
EnableVectorSearch bool
|
|
|
|
// VectorDimensions is the default dimension for vector embeddings
|
|
VectorDimensions int
|
|
|
|
// SQLite configuration
|
|
SQLite SQLiteConfig
|
|
|
|
// Datastore configuration (Hanzo Datastore)
|
|
Datastore DatastoreConfig
|
|
|
|
// IsDev enables development mode logging
|
|
IsDev bool
|
|
}
|
|
|
|
// SQLiteConfig holds SQLite-specific configuration
|
|
type SQLiteConfig struct {
|
|
// MaxOpenConns for concurrent reads
|
|
MaxOpenConns int
|
|
|
|
// MaxIdleConns for connection pooling
|
|
MaxIdleConns int
|
|
|
|
// BusyTimeout in milliseconds before giving up on locked DB
|
|
BusyTimeout int
|
|
|
|
// JournalMode (WAL recommended for concurrency)
|
|
JournalMode string
|
|
|
|
// Synchronous mode (NORMAL for balance of safety/speed)
|
|
Synchronous string
|
|
|
|
// CacheSize in KB (negative = KB, positive = pages)
|
|
CacheSize int
|
|
|
|
// QueryTimeout for SELECT queries
|
|
QueryTimeout time.Duration
|
|
}
|
|
|
|
// DatastoreConfig holds Hanzo Datastore (Datastore) configuration
|
|
type DatastoreConfig struct {
|
|
// MaxOpenConns for parallel queries
|
|
MaxOpenConns int
|
|
|
|
// MaxIdleConns for connection pooling
|
|
MaxIdleConns int
|
|
|
|
// ConnMaxLifetime for connection recycling
|
|
ConnMaxLifetime time.Duration
|
|
|
|
// Compression method (lz4, zstd, etc.)
|
|
Compression string
|
|
|
|
// QueryTimeout for datastore queries
|
|
QueryTimeout time.Duration
|
|
}
|
|
|
|
// DefaultConfig returns a default configuration
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
DataDir: "./data",
|
|
EnableDatastore: false,
|
|
EnableVectorSearch: true,
|
|
VectorDimensions: 1536, // OpenAI ada-002 dimensions
|
|
SQLite: SQLiteConfig{
|
|
MaxOpenConns: 120,
|
|
MaxIdleConns: 15,
|
|
BusyTimeout: 10000, // 10 seconds
|
|
JournalMode: "WAL",
|
|
Synchronous: "NORMAL",
|
|
CacheSize: -16000, // 16MB
|
|
QueryTimeout: 30 * time.Second,
|
|
},
|
|
Datastore: DatastoreConfig{
|
|
MaxOpenConns: 25,
|
|
MaxIdleConns: 5,
|
|
ConnMaxLifetime: time.Hour,
|
|
Compression: "lz4",
|
|
QueryTimeout: 60 * time.Second,
|
|
},
|
|
IsDev: false,
|
|
}
|
|
}
|
|
|
|
// Manager is the main entry point for database operations.
|
|
// It manages multiple database layers and provides unified access.
|
|
type Manager struct {
|
|
config *Config
|
|
|
|
// Per-tenant SQLite stores. The lifecycle — open on demand, bound, evict
|
|
// the coldest idle handle, and the OnOpen/OnClose seam WAL replication
|
|
// attaches to — lives in orm, because it is not commerce's problem and
|
|
// commerce's private version of it (a userDBs and an orgDBs map, closed
|
|
// only by Manager.Close) grew a file descriptor per tenant ever touched
|
|
// and never gave one back.
|
|
tenants *ormdb.Namespaces[DB]
|
|
|
|
// Hanzo Datastore (shared)
|
|
datastoreDB Datastore
|
|
|
|
// masterKey is the 32-byte KMS-sourced at-rest encryption master key
|
|
// (COMMERCE_KMS_MASTER_KEY). Nil => per-tenant SQLite files are unencrypted
|
|
// (dev/CI). Resolved once in NewManager and passed to every tenant store.
|
|
masterKey []byte
|
|
}
|
|
|
|
// NewManager creates a new database manager
|
|
func NewManager(cfg *Config) (*Manager, error) {
|
|
if cfg == nil {
|
|
cfg = DefaultConfig()
|
|
}
|
|
|
|
if cfg.UserDataDir == "" {
|
|
cfg.UserDataDir = cfg.DataDir + "/users"
|
|
}
|
|
if cfg.MaxOpenTenants <= 0 {
|
|
cfg.MaxOpenTenants = defaultMaxOpenTenants
|
|
}
|
|
if cfg.TenantIdleTTL == 0 {
|
|
cfg.TenantIdleTTL = defaultTenantIdleTTL
|
|
}
|
|
|
|
// Decide at-rest encryption posture ONCE, from COMMERCE_KMS_MASTER_KEY. On a
|
|
// non-encrypting (pure-Go) build this hard-errors when the key is set rather
|
|
// than silently persisting plaintext money data.
|
|
// An embedder-supplied key wins and skips the env entirely; otherwise the
|
|
// posture is decided from COMMERCE_KMS_MASTER_KEY exactly as before.
|
|
var err error
|
|
masterKey := cfg.MasterKey
|
|
if masterKey == nil {
|
|
if masterKey, err = ResolveMasterKey(); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if len(masterKey) != 32 {
|
|
return nil, fmt.Errorf("commerce: embedder MasterKey must be 32 bytes, got %d", len(masterKey))
|
|
}
|
|
|
|
m := &Manager{
|
|
config: cfg,
|
|
masterKey: masterKey,
|
|
}
|
|
|
|
m.tenants, err = ormdb.NewNamespaces(ormdb.NamespacesConfig[DB]{
|
|
Dir: cfg.DataDir,
|
|
MaxOpen: cfg.MaxOpenTenants,
|
|
IdleTTL: cfg.TenantIdleTTL,
|
|
PathFor: m.tenantPath,
|
|
Open: m.openTenant,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Initialize Hanzo Datastore if enabled
|
|
if cfg.EnableDatastore && cfg.DatastoreDSN != "" {
|
|
datastore, err := NewDatastore(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.datastoreDB = datastore
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// tenantPath places a tenant's file.
|
|
//
|
|
// An ORG's file is placed by hanzoai/namespace — DataDir/orgs/<slug>/commerce.db
|
|
// — so the path and the at-rest key are two renderings of ONE name (see
|
|
// tenantNamespace) and cannot drift apart. It is also the layout every other
|
|
// Hanzo service writes, so an org's commerce store sits beside its other stores
|
|
// under one directory instead of in a tree only commerce knows about.
|
|
//
|
|
// A USER's file stays commerce's own <UserDataDir>/<id>/data.db, because
|
|
// namespace has no layout for a user and inventing one here would be a second
|
|
// convention. That half is unencryptable for the same reason.
|
|
//
|
|
// The registry's single Dir is ignored, which means opting out of its
|
|
// containment check — so the id is validated here instead. A PathFor that leaves
|
|
// the registry's tree owns the guarantee the registry can no longer make.
|
|
func (m *Manager) tenantPath(_ string, n ormdb.Namespace) (string, error) {
|
|
typ, id, ok := splitTenant(n)
|
|
if !ok || !isSafeTenantID(id) {
|
|
return "", fmt.Errorf("db: unsafe namespace %q", n)
|
|
}
|
|
if typ == tenantUser {
|
|
return m.config.UserDataDir + "/" + id + "/data.db", nil
|
|
}
|
|
ns, err := tenantNamespace(typ, id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return namespace.Path(m.config.DataDir, ns, tenantSubsystem)
|
|
}
|
|
|
|
// splitTenant reads a namespace back as the (type, id) pair commerce stores
|
|
// under. A namespace is opaque to orm — one string, one file — so the pair
|
|
// lives here, where the two roots and the SQLite tenant columns need it.
|
|
func splitTenant(n ormdb.Namespace) (typ, id string, ok bool) {
|
|
typ, id, ok = strings.Cut(string(n), "/")
|
|
return typ, id, ok && typ != "" && id != ""
|
|
}
|
|
|
|
// openTenant opens one tenant's store. The registry decides WHEN a store is
|
|
// open; this decides WHAT one is — same pragmas, same vector settings, same
|
|
// at-rest key for every tenant.
|
|
func (m *Manager) openTenant(n ormdb.Namespace, path string) (DB, error) {
|
|
typ, id, ok := splitTenant(n)
|
|
if !ok {
|
|
return nil, fmt.Errorf("db: unsafe namespace %q", n)
|
|
}
|
|
return NewSQLiteDB(&SQLiteDBConfig{
|
|
Path: path,
|
|
Config: m.config.SQLite,
|
|
EnableVectorSearch: m.config.EnableVectorSearch,
|
|
VectorDimensions: m.config.VectorDimensions,
|
|
TenantID: id,
|
|
TenantType: typ,
|
|
MasterKey: m.masterKey,
|
|
})
|
|
}
|
|
|
|
// isSafeTenantID reports whether id is safe to use as a single filesystem path
|
|
// segment for a per-tenant SQLite store. The tenant id is the org (or user)
|
|
// namespace, which derives from a request-supplied — gateway/JWT-verified —
|
|
// owner/subject value, so it must never be able to escape the data dir (e.g.
|
|
// "..", "a/b", "/etc/passwd"). Reject anything that is not a clean single
|
|
// segment; callers fail closed on the returned error.
|
|
func isSafeTenantID(id string) bool {
|
|
if id == "" || id == "." || id == ".." {
|
|
return false
|
|
}
|
|
if strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") {
|
|
return false
|
|
}
|
|
// Leading-dot names (dotfiles / hidden dirs) are rejected — an org "."-prefixed
|
|
// name shouldn't be able to create a hidden per-tenant dir (Red LOW-1). This is
|
|
// pure path-safety, so it also applies to the app's own Org() calls (which
|
|
// never use dot-names). The RESERVED-namespace policy (system/admin/default) is
|
|
// intentionally NOT here: this function guards BOTH untrusted tenant requests
|
|
// AND the app's legitimate Manager.Org("system") systemDB-fallback + test
|
|
// harness, so rejecting "system" here would break boot. That policy lives at
|
|
// the tenant boundary — datastore.NewNamespaced.
|
|
if strings.HasPrefix(id, ".") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// User returns the database for a specific user
|
|
func (m *Manager) User(userID string) (DB, error) { return m.tenant(tenantUser, userID) }
|
|
|
|
// Org returns the database for a specific organization
|
|
func (m *Manager) Org(orgID string) (DB, error) { return m.tenant(tenantOrg, orgID) }
|
|
|
|
// tenant returns a DB view of one tenant's store.
|
|
//
|
|
// The returned value pins nothing (see tenantDB): it borrows the tenant's
|
|
// handle per operation, so callers may hold it for as long as they like without
|
|
// keeping a file descriptor. The store is opened here rather than on first use
|
|
// so this still reports an unopenable tenant as an error — the namespaced
|
|
// datastore resolver depends on that to fail closed rather than route a
|
|
// merchant's rows somewhere shared.
|
|
func (m *Manager) tenant(typ, id string) (DB, error) {
|
|
if !isSafeTenantID(id) {
|
|
return nil, fmt.Errorf("db: unsafe %s id %q", typ, id)
|
|
}
|
|
t := ormdb.Namespace(typ + "/" + id)
|
|
if err := m.tenants.With(context.Background(), t, func(DB) error { return nil }); err != nil {
|
|
if errors.Is(err, ormdb.ErrClosed) {
|
|
return nil, ErrDatabaseClosed
|
|
}
|
|
return nil, err
|
|
}
|
|
return newTenantDB(m.tenants, t), nil
|
|
}
|
|
|
|
// Datastore returns the Hanzo Datastore for deep analytics queries
|
|
func (m *Manager) Datastore() Datastore {
|
|
return m.datastoreDB
|
|
}
|
|
|
|
// Encrypted reports whether per-tenant SQLite files are SQLCipher-encrypted at
|
|
// rest (i.e. COMMERCE_KMS_MASTER_KEY was supplied and this build can encrypt).
|
|
func (m *Manager) Encrypted() bool {
|
|
return m.masterKey != nil
|
|
}
|
|
|
|
// Close closes all database connections
|
|
func (m *Manager) Close() error {
|
|
var lastErr error
|
|
|
|
// Close every open tenant store. Further User/Org calls report
|
|
// ErrDatabaseClosed.
|
|
if err := m.tenants.Close(); err != nil {
|
|
lastErr = err
|
|
}
|
|
|
|
// Close Hanzo Datastore
|
|
if m.datastoreDB != nil {
|
|
if err := m.datastoreDB.Close(); err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// DB is the main database interface for user/org SQLite databases
|
|
type DB interface {
|
|
// Core operations
|
|
Get(ctx context.Context, key Key, dst interface{}) error
|
|
Put(ctx context.Context, key Key, src interface{}) (Key, error)
|
|
Delete(ctx context.Context, key Key) error
|
|
|
|
// Batch operations
|
|
GetMulti(ctx context.Context, keys []Key, dst interface{}) error
|
|
PutMulti(ctx context.Context, keys []Key, src interface{}) ([]Key, error)
|
|
DeleteMulti(ctx context.Context, keys []Key) error
|
|
|
|
// Query
|
|
Query(kind string) Query
|
|
|
|
// Vector search (sqlite-vec)
|
|
VectorSearch(ctx context.Context, opts *VectorSearchOptions) ([]VectorResult, error)
|
|
PutVector(ctx context.Context, kind string, id string, vector []float32, metadata map[string]interface{}) error
|
|
|
|
// Key management
|
|
NewKey(kind string, stringID string, intID int64, parent Key) Key
|
|
NewIncompleteKey(kind string, parent Key) Key
|
|
AllocateIDs(kind string, parent Key, n int) ([]Key, error)
|
|
|
|
// Transactions
|
|
RunInTransaction(ctx context.Context, fn func(tx Transaction) error, opts *TransactionOptions) error
|
|
|
|
// Lifecycle
|
|
Close() error
|
|
|
|
// Tenant info
|
|
TenantID() string
|
|
TenantType() string
|
|
}
|
|
|
|
// Sequencer is a backend that can hand out a durable, strictly increasing
|
|
// number under a name — ATOMICALLY, so that two concurrent callers, in two
|
|
// goroutines or in two replicas, can never receive the same one.
|
|
//
|
|
// It exists because nothing else in this store can allocate a unique number.
|
|
// Put is a blind upsert (ON CONFLICT … DO UPDATE), so a second writer
|
|
// OVERWRITES rather than being refused; datastore.RunInTransaction is a no-op
|
|
// that opens no transaction at all; and DB.RunInTransaction, while real, runs
|
|
// at the Postgres default isolation (READ COMMITTED), where read-modify-write
|
|
// on a counter row lets two transactions both read N and both commit N+1. So
|
|
// every "unique" identifier in this codebase above the storage layer is either
|
|
// a deterministic hash (which makes duplicates COLLAPSE onto one row rather
|
|
// than allocate) or a check-then-write with a TOCTOU window.
|
|
//
|
|
// That is fine for idempotent dedup, where collapsing is the goal. It is not
|
|
// fine for allocation, where two callers must walk away with two DIFFERENT
|
|
// numbers. Where the number identifies whose money is whose — an XRPL
|
|
// destination tag on a pooled custody account — a check-then-write is not a
|
|
// guarantee, and this is the primitive that makes it one.
|
|
//
|
|
// It is deliberately NOT part of the DB interface. Allocation is a capability,
|
|
// not a requirement: a backend that cannot do it atomically must fail to
|
|
// satisfy this rather than offer a racy imitation, so callers type-assert and
|
|
// REFUSE when the assertion fails, instead of degrading to a check-then-write.
|
|
//
|
|
// The counter starts at 0 and 0 IS a value it hands out — the first call
|
|
// returns 0, not 1. Callers that treat 0 as "unset" must say so with a
|
|
// separate presence flag, never by skipping it.
|
|
type Sequencer interface {
|
|
// NextSequence atomically increments the named counter and returns its new
|
|
// value. The first call for a name returns 0.
|
|
NextSequence(ctx context.Context, name string) (uint64, error)
|
|
}
|
|
|
|
// Counter is a backend that can add to a durable named total ATOMICALLY and
|
|
// return the total after the addition: the Sequencer guarantee for a delta of
|
|
// any size. Two concurrent callers adding a and b each get back a different
|
|
// total, so each owns its own span (total-delta, total] of the running sum and
|
|
// no span is counted twice.
|
|
//
|
|
// It is what makes an exact running sum possible over a store whose writes are
|
|
// blind upserts. The usage ledger debits whole cents but is priced in
|
|
// micro-dollars; the sub-cent remainder accumulates here instead of being
|
|
// rounded away. Like Sequencer it is a capability: a caller that needs it
|
|
// refuses when the assertion fails rather than imitate it.
|
|
//
|
|
// It shares the _sequences table with Sequencer. A counter's names are the
|
|
// caller's, kept apart from a sequence's by their prefix.
|
|
type Counter interface {
|
|
// Add atomically adds delta to the named total, which starts at 0, and
|
|
// returns the new total.
|
|
Add(ctx context.Context, name string, delta int64) (int64, error)
|
|
}
|
|
|
|
// Swapper is a backend that can write an entity only while it is still the one a
|
|
// caller read: a compare-and-swap on the stamp every write sets, its updatedAt. A
|
|
// read-modify-write that swaps instead of putting cannot lose another writer's
|
|
// change, in another goroutine or another replica: the second writer's swap finds
|
|
// the stamp moved, writes nothing, and reads again. Put stays a blind upsert.
|
|
//
|
|
// Like Counter it is a capability: a caller that needs it refuses when the
|
|
// assertion fails rather than imitate it with a check-then-write.
|
|
type Swapper interface {
|
|
// Swap writes src under key when the stored entity's updatedAt is still prev
|
|
// (its JSON text, as stored), or, with prev "", when nothing is stored under
|
|
// key yet. It reports whether it wrote.
|
|
Swap(ctx context.Context, key Key, prev string, src any) (bool, error)
|
|
}
|
|
|
|
// sequenceDDL is the ONE definition of the counter table, identical in shape on
|
|
// both backends: a name, and the last value handed out under it.
|
|
const sequenceDDL = `CREATE TABLE IF NOT EXISTS _sequences (
|
|
name TEXT PRIMARY KEY,
|
|
value BIGINT NOT NULL DEFAULT 0
|
|
)`
|
|
|
|
// Datastore is the interface for Hanzo Datastore (Datastore) analytics queries
|
|
type Datastore interface {
|
|
// Query executes datastore queries
|
|
Query(ctx context.Context, query string, args ...interface{}) (DatastoreRows, error)
|
|
|
|
// Select scans results into a destination slice
|
|
Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error
|
|
|
|
// Exec executes a non-query statement
|
|
Exec(ctx context.Context, query string, args ...interface{}) error
|
|
|
|
// Batch insert for high-throughput data ingestion
|
|
PrepareBatch(ctx context.Context, query string) (DatastoreBatch, error)
|
|
|
|
// AsyncInsert for fire-and-forget event logging
|
|
AsyncInsert(ctx context.Context, query string, wait bool, args ...interface{}) error
|
|
|
|
// Close closes the datastore connection
|
|
Close() error
|
|
}
|
|
|
|
// DatastoreRows represents datastore query results
|
|
type DatastoreRows interface {
|
|
Next() bool
|
|
Scan(dest ...interface{}) error
|
|
ScanStruct(dest interface{}) error
|
|
Columns() []string
|
|
Close() error
|
|
Err() error
|
|
}
|
|
|
|
// DatastoreBatch for bulk inserts into Hanzo Datastore
|
|
type DatastoreBatch interface {
|
|
Append(v ...interface{}) error
|
|
AppendStruct(v interface{}) error
|
|
Flush() error
|
|
Send() error
|
|
Abort() error
|
|
Rows() int
|
|
Close() error
|
|
}
|
|
|
|
// VectorSearchOptions configures vector similarity search
|
|
type VectorSearchOptions struct {
|
|
// Kind is the entity type to search
|
|
Kind string
|
|
|
|
// Vector is the query vector
|
|
Vector []float32
|
|
|
|
// Limit is the maximum number of results
|
|
Limit int
|
|
|
|
// MinScore filters results below this similarity score
|
|
MinScore float32
|
|
|
|
// Filters are additional SQL conditions
|
|
Filters map[string]interface{}
|
|
}
|
|
|
|
// VectorResult represents a vector search result
|
|
type VectorResult struct {
|
|
// ID is the entity identifier
|
|
ID string
|
|
|
|
// Score is the similarity score (0-1, higher is more similar)
|
|
Score float32
|
|
|
|
// Metadata is additional data stored with the vector
|
|
Metadata map[string]interface{}
|
|
}
|
|
|
|
// Transaction represents a database transaction
|
|
type Transaction interface {
|
|
Get(key Key, dst interface{}) error
|
|
Put(key Key, src interface{}) (Key, error)
|
|
Delete(key Key) error
|
|
Query(kind string) Query
|
|
}
|
|
|
|
// TransactionOptions configures transaction behavior
|
|
type TransactionOptions struct {
|
|
// ReadOnly indicates this is a read-only transaction
|
|
ReadOnly bool
|
|
|
|
// MaxAttempts for retries on conflict
|
|
MaxAttempts int
|
|
|
|
// Isolation level (for SQL databases)
|
|
Isolation IsolationLevel
|
|
}
|
|
|
|
// IsolationLevel represents transaction isolation levels
|
|
type IsolationLevel int
|
|
|
|
const (
|
|
IsolationDefault IsolationLevel = iota
|
|
IsolationReadUncommitted
|
|
IsolationReadCommitted
|
|
IsolationRepeatableRead
|
|
IsolationSerializable
|
|
)
|
|
|
|
// Key represents a unique identifier for an entity
|
|
type Key interface {
|
|
// Kind returns the entity kind/table name
|
|
Kind() string
|
|
|
|
// StringID returns the string identifier (if any)
|
|
StringID() string
|
|
|
|
// IntID returns the integer identifier (if any)
|
|
IntID() int64
|
|
|
|
// Parent returns the parent key (for hierarchical keys)
|
|
Parent() Key
|
|
|
|
// Namespace returns the namespace/tenant
|
|
Namespace() string
|
|
|
|
// Incomplete returns true if this key needs an ID assigned
|
|
Incomplete() bool
|
|
|
|
// Encode returns an encoded string representation
|
|
Encode() string
|
|
|
|
// Equal checks if two keys are the same
|
|
Equal(other Key) bool
|
|
}
|
|
|
|
// Query provides a fluent interface for querying entities
|
|
type Query interface {
|
|
// Filtering
|
|
Filter(filterStr string, value interface{}) Query
|
|
FilterField(fieldPath string, op string, value interface{}) Query
|
|
|
|
// Ordering
|
|
Order(fieldPath string) Query
|
|
OrderDesc(fieldPath string) Query
|
|
|
|
// Pagination
|
|
Limit(limit int) Query
|
|
Offset(offset int) Query
|
|
|
|
// Projection
|
|
Project(fieldNames ...string) Query
|
|
Distinct() Query
|
|
|
|
// Ancestor queries (for hierarchical data)
|
|
Ancestor(ancestor Key) Query
|
|
|
|
// Execution
|
|
GetAll(ctx context.Context, dst interface{}) ([]Key, error)
|
|
First(ctx context.Context, dst interface{}) (Key, error)
|
|
Count(ctx context.Context) (int, error)
|
|
Keys(ctx context.Context) ([]Key, error)
|
|
Run(ctx context.Context) Iterator
|
|
|
|
// Cursors for pagination
|
|
Start(cursor Cursor) Query
|
|
End(cursor Cursor) Query
|
|
}
|
|
|
|
// Iterator allows iterating over query results
|
|
type Iterator interface {
|
|
Next(dst interface{}) (Key, error)
|
|
Cursor() (Cursor, error)
|
|
}
|
|
|
|
// Cursor represents a position in a result set
|
|
type Cursor interface {
|
|
String() string
|
|
}
|
|
|
|
// Entity is the interface that all model entities should implement
|
|
type Entity interface {
|
|
// Kind returns the entity kind/table name
|
|
Kind() string
|
|
}
|
|
|
|
// Syncable entities can be synced to Hanzo Datastore
|
|
type Syncable interface {
|
|
Entity
|
|
|
|
// SyncToDatastore returns true if this entity should be synced
|
|
SyncToDatastore() bool
|
|
}
|