Files
zeekayandHanzo Dev dcda39b439 Bound a limitless GetAll; move its whole kind page by page
A GetAll that states no limit could return as many rows as the store
holds, and the store is older and bigger than any process that reads it.
It now returns at most MaxGetAll rows (ten thousand) on every backend
that answers one, and a caller that needs more streams with Run/Next —
the path that stops when the reader is done.

Move read its kinds with a bare GetAll, so the bound would have truncated
a kind bigger than one page and moved it half. It pages now: an explicit
limit of a full page at a time, Put upserting so a row read twice costs
nothing, and the last short page ending the walk.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 05:07:30 -07:00

83 lines
3.2 KiB
Go

package orm
import (
"context"
"encoding/json"
"fmt"
ormdb "github.com/hanzoai/orm/db"
)
// Move copies entities from one store to another.
//
// This is what makes the backends a choice rather than a commitment: a service
// starts on SQLite because it needs no server, moves to Hanzo SQL when it
// outgrows one file, and takes a copy into Hanzo Datastore when the questions
// turn analytical. DocDB is document semantics over the same SQL rows, so a move
// there is the same operation.
//
// It is written against the DB interface and nothing else, so it works for any
// pair — including a pair that did not exist when this was written. There is no
// per-backend branch here, and there must not be one: the moment a move knows
// which store it is talking to, the interface has stopped being the contract.
//
// Entities cross as their stored bytes. Decoding into a concrete type would
// force this to know every caller's types, and would silently drop any field the
// destination's Go struct has since removed — a migration that quietly discards
// data is worse than one that refuses.
//
// It is IDEMPOTENT: Put is an unconditional upsert, so a move that fails partway
// is re-run rather than unpicked, and a move onto a populated store overwrites by
// key rather than duplicating. It is deliberately NOT a delete: the source is
// left intact, so a bad move is recovered by pointing back at it.
//
// With no kinds named it moves everything registered. Naming them moves only
// those, which is how a large store is moved in pieces.
func Move(ctx context.Context, src, dst DB, kinds ...string) (Moved, error) {
if src == nil || dst == nil {
return Moved{}, fmt.Errorf("orm: Move needs both a source and a destination")
}
if len(kinds) == 0 {
kinds = Kinds()
}
moved := Moved{ByKind: map[string]int{}}
for _, kind := range kinds {
// Page the read. A bare GetAll is bounded by ormdb.MaxGetAll — the bound that
// keeps one response from growing with the store — and a move is not a
// response: it is the whole kind, so it asks page by page. Put upserts,
// so a row read twice costs nothing.
for off := 0; ; {
var raw []json.RawMessage
keys, err := src.Query(kind).Offset(off).Limit(ormdb.MaxGetAll).GetAll(ctx, &raw)
if err != nil {
return moved, fmt.Errorf("orm: Move: reading %q: %w", kind, err)
}
if len(keys) != len(raw) {
return moved, fmt.Errorf("orm: Move: %q returned %d keys for %d entities", kind, len(keys), len(raw))
}
for i, k := range keys {
// The key is rebuilt on the DESTINATION, because a Key carries the
// store that made it; handing a source key to another store is how a
// move silently writes nowhere.
if _, err := dst.Put(ctx, dst.NewKey(kind, k.StringID(), k.IntID(), nil), &raw[i]); err != nil {
return moved, fmt.Errorf("orm: Move: writing %s/%s: %w", kind, k.StringID(), err)
}
moved.ByKind[kind]++
moved.Total++
}
if len(keys) < ormdb.MaxGetAll {
break
}
off += ormdb.MaxGetAll
}
}
return moved, nil
}
// Moved reports what a Move carried, per kind, so a caller can compare it with
// what it expected rather than trust that the call returned nil.
type Moved struct {
ByKind map[string]int
Total int
}