Files
zeekay 3ccbe432ae luxfi/esign v0.3: Hanzo Base as the default backend store
Make Hanzo Base — per-tenant SQLite + extension runtime (HIP-0105,
HIP-0107, HIP-0302) — the canonical production backend. MemStore stays
for tests and ephemeral local dev.

  * pkg/basestore — BaseStore implements api.Store on a core.App.
    Single esign_envelopes collection holds the canonical JSON
    envelope as the body field; state + doc_hash + version +
    updated_at are mirrored as indexed columns for cheap dashboard
    queries, but the JSON blob is the source of truth so envelope
    schema changes never require a SQLite migration.
    ensureCollection is idempotent.
    Indexes: (state, updated_at DESC), (doc_hash).

  * pkg/basestore/store_test.go — round-trip, upsert, missing-id,
    empty-id rejection. Uses t.TempDir() so each test isolates its
    own SQLite file.

  * pkg/api/handlers.go — bump default envelope ID generator from
    "env_<8 hex>" to "esign<16 hex>" (21 chars, ^[a-z0-9]+$) to
    match Base's default record-id format and clear the 15-char
    minimum. ID stays deterministic from the document so identical
    bytes get the same envelope ID.

  * cmd/esignd — default to BaseStore against `--data-dir esign_data`.
    Pass `--memory` for the in-process MemStore (ephemeral, tests).
    buildStore handles bootstrap + cleanup so the rest of main stays
    a clean wire-up.

Test summary: 4 packages, 26 tests, all green.
2026-06-07 11:43:52 -07:00

135 lines
4.8 KiB
Go

// Copyright 2026 Lux Industries Inc. and the luxfi/esign authors.
// Licensed under the Apache License, Version 2.0 (see LICENSE).
// Package basestore adapts luxfi/esign's api.Store onto Hanzo Base —
// the per-tenant SQLite + extension runtime that backs every Hanzo
// service. This makes Base the canonical production backend; tests and
// in-process callers can keep using api.MemStore.
//
// One collection ("esign_envelopes") holds the entire envelope blob as
// canonical JSON in the `body` field, alongside a few indexed columns
// (state, doc_hash, updated_at) for cheap dashboard queries. Schema is
// idempotently ensured on first Put.
//
// Why one big blob instead of a normalized schema? The envelope shape
// is owned by pkg/envelope and evolves under semver — making BaseStore
// shape-agnostic means an envelope schema change does not require a
// SQLite migration. The indexed columns mirror only what
// dashboards / list endpoints sort and filter on, so they stay tiny.
package basestore
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/hanzoai/base/core"
"github.com/luxfi/esign/pkg/envelope"
)
// collectionName is the single Base collection BaseStore owns. Kept
// short, snake_case, prefixed with "esign_" so it is obviously
// luxfi/esign's collection in a multi-service Base instance.
const collectionName = "esign_envelopes"
// BaseStore implements api.Store on top of a Hanzo Base core.App.
// Construct with NewBaseStore; the constructor idempotently ensures the
// backing collection + indexes exist.
type BaseStore struct {
app core.App
col *core.Collection
}
// NewBaseStore wires a BaseStore against `app` and ensures the
// esign_envelopes collection exists. Safe to call repeatedly; if the
// collection is already present the schema check is a single read.
func NewBaseStore(app core.App) (*BaseStore, error) {
if app == nil {
return nil, errors.New("basestore: app is required")
}
col, err := ensureCollection(app)
if err != nil {
return nil, fmt.Errorf("basestore: ensure collection: %w", err)
}
return &BaseStore{app: app, col: col}, nil
}
// Put inserts or replaces the envelope. ID-based upsert: if a record
// already exists for the envelope ID, fields are patched and saved.
// Validates by the collection's hook chain so any custom rules engaged
// for esign_envelopes still run.
func (s *BaseStore) Put(_ context.Context, e *envelope.Envelope) error {
if e == nil || e.ID == "" {
return errors.New("basestore: envelope id required")
}
body, err := json.Marshal(e)
if err != nil {
return fmt.Errorf("basestore: marshal envelope: %w", err)
}
rec, err := s.app.FindRecordById(s.col, e.ID)
if err != nil {
// Not found → new record. Base distinguishes "no rows" from a
// real error inside FindRecordById; we treat any error as
// "create" since the upsert is the desired behaviour.
rec = core.NewRecord(s.col)
rec.Id = e.ID
}
rec.Set("body", string(body))
rec.Set("state", e.State.String())
rec.Set("doc_hash", hex.EncodeToString(e.DocHash[:]))
rec.Set("version", e.Version)
rec.Set("updated_at", e.UpdatedAt)
return s.app.Save(rec)
}
// Get returns a copy of the envelope by ID. Decodes the canonical JSON
// blob back into envelope.Envelope; the indexed columns are advisory
// only and are NOT trusted as the source of truth.
func (s *BaseStore) Get(_ context.Context, id string) (*envelope.Envelope, error) {
rec, err := s.app.FindRecordById(s.col, id)
if err != nil {
return nil, fmt.Errorf("basestore: not found: %s", id)
}
body := rec.GetString("body")
if body == "" {
return nil, fmt.Errorf("basestore: empty body for %s", id)
}
var e envelope.Envelope
if err := json.Unmarshal([]byte(body), &e); err != nil {
return nil, fmt.Errorf("basestore: decode envelope %s: %w", id, err)
}
return &e, nil
}
// ensureCollection makes the esign_envelopes collection if it isn't
// already present. Idempotent; cheap on the warm path.
func ensureCollection(app core.App) (*core.Collection, error) {
if col, err := app.FindCollectionByNameOrId(collectionName); err == nil {
return col, nil
}
col := core.NewBaseCollection(collectionName)
col.Fields.Add(
&core.TextField{Name: "body", Required: true, Max: 16 * 1024 * 1024},
&core.TextField{Name: "state", Required: true, Max: 32},
&core.TextField{Name: "doc_hash", Required: true, Min: 64, Max: 64},
&core.NumberField{Name: "version", Required: true},
&core.AutodateField{Name: "updated_at", OnUpdate: true},
)
// Indexes the dashboard cares about: state + updated_at for list
// queries, doc_hash for de-dup lookups.
col.AddIndex("idx_esign_envelopes_state", false, "state, updated_at DESC", "")
col.AddIndex("idx_esign_envelopes_doc_hash", false, "doc_hash", "")
if err := app.Save(col); err != nil {
return nil, err
}
return col, nil
}