mirror of
https://github.com/luxfi/esign.git
synced 2026-10-30 16:58:18 +00:00
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.
135 lines
4.8 KiB
Go
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
|
|
}
|