334 lines
10 KiB
Go
334 lines
10 KiB
Go
package state
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/db/dialect"
|
|
)
|
|
|
|
// InstanceStateEntry is a revisioned value owned by one plugin instance.
|
|
// Deleted entries are retained as tombstones so a delete followed by a stale
|
|
// recreate cannot reset the revision back to one.
|
|
type InstanceStateEntry struct {
|
|
InstanceID string
|
|
Key string
|
|
Value json.RawMessage
|
|
Revision int64
|
|
WriterKind string
|
|
UpdatedAt time.Time
|
|
Deleted bool
|
|
}
|
|
|
|
// ConflictError reports the revision that won a conditional state write.
|
|
// The current value is intentionally not included. Browser callers only get
|
|
// it when they still have a separate read permission.
|
|
type ConflictError struct {
|
|
CurrentRevision int64
|
|
}
|
|
|
|
func (e *ConflictError) Error() string {
|
|
return fmt.Sprintf("plugin instance state: revision conflict (current revision %d)", e.CurrentRevision)
|
|
}
|
|
|
|
func (e *ConflictError) Unwrap() error { return ErrConflict }
|
|
|
|
// InstanceStore persists browser and agent-authored state for a plugin
|
|
// instance. The mutex makes the read/check/write sequence atomic for every
|
|
// caller in this process; the PostgreSQL row lock covers concurrent processes.
|
|
type InstanceStore struct {
|
|
db *sqlx.DB
|
|
ro *sqlx.DB
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewInstanceStore creates the revisioned instance-state table if needed.
|
|
func NewInstanceStore(pool *db.Pool) (*InstanceStore, error) {
|
|
if pool == nil || pool.Writer() == nil || pool.Reader() == nil {
|
|
return nil, errors.New("plugin instance state: database pool is required")
|
|
}
|
|
s := &InstanceStore{db: pool.Writer(), ro: pool.Reader()}
|
|
if err := s.initSchema(); err != nil {
|
|
return nil, fmt.Errorf("plugin instance state schema init: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *InstanceStore) initSchema() error {
|
|
_, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS plugin_instance_state (
|
|
id TEXT PRIMARY KEY,
|
|
plugin_instance_id TEXT NOT NULL,
|
|
state_key TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
writer_kind TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE (plugin_instance_id, state_key)
|
|
);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
// Get returns the current value. found is false for a missing key or a
|
|
// tombstone, but the returned entry still carries its revision when a
|
|
// tombstone exists so callers can report a precise conflict.
|
|
func (s *InstanceStore) Get(ctx context.Context, instanceID, key string) (InstanceStateEntry, bool, error) {
|
|
var entry InstanceStateEntry
|
|
var raw, updatedAt string
|
|
var deleted int
|
|
err := s.ro.QueryRowContext(ctx, s.ro.Rebind(`
|
|
SELECT plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted
|
|
FROM plugin_instance_state
|
|
WHERE plugin_instance_id = ? AND state_key = ?
|
|
`), instanceID, key).Scan(
|
|
&entry.InstanceID, &entry.Key, &raw, &entry.Revision, &entry.WriterKind, &updatedAt, &deleted,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return InstanceStateEntry{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return InstanceStateEntry{}, false, err
|
|
}
|
|
entry.Value = json.RawMessage(raw)
|
|
entry.Deleted = deleted != 0
|
|
entry.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt)
|
|
if err != nil {
|
|
return InstanceStateEntry{}, false, fmt.Errorf("parse instance state updated_at: %w", err)
|
|
}
|
|
return entry, !entry.Deleted, nil
|
|
}
|
|
|
|
// List returns live values in stable key order. Tombstones are omitted.
|
|
func (s *InstanceStore) List(ctx context.Context, instanceID string) ([]InstanceStateEntry, error) {
|
|
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`
|
|
SELECT plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted
|
|
FROM plugin_instance_state
|
|
WHERE plugin_instance_id = ? AND deleted = 0
|
|
ORDER BY state_key
|
|
`), instanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
var entries []InstanceStateEntry
|
|
for rows.Next() {
|
|
var entry InstanceStateEntry
|
|
var raw, updatedAt string
|
|
var deleted int
|
|
if err := rows.Scan(&entry.InstanceID, &entry.Key, &raw, &entry.Revision, &entry.WriterKind, &updatedAt, &deleted); err != nil {
|
|
return nil, err
|
|
}
|
|
entry.Value = json.RawMessage(raw)
|
|
entry.Deleted = deleted != 0
|
|
entry.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse instance state updated_at: %w", err)
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// Set writes a value at the expected revision. A nil expectation is an
|
|
// unconditional internal write; a pointer to zero creates a new key, and a
|
|
// positive value must match the current revision.
|
|
func (s *InstanceStore) Set(
|
|
ctx context.Context,
|
|
instanceID, key string,
|
|
value json.RawMessage,
|
|
expectedRevision *int64,
|
|
writerKind string,
|
|
) (InstanceStateEntry, error) {
|
|
if instanceID == "" || key == "" {
|
|
return InstanceStateEntry{}, errors.New("plugin instance state: instance id and key are required")
|
|
}
|
|
if !json.Valid(value) {
|
|
return InstanceStateEntry{}, errors.New("plugin instance state: value must be valid JSON")
|
|
}
|
|
if writerKind == "" {
|
|
writerKind = "unknown"
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return InstanceStateEntry{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
current, found, err := selectInstanceState(ctx, tx, s.db.DriverName(), instanceID, key)
|
|
if err != nil {
|
|
return InstanceStateEntry{}, err
|
|
}
|
|
if err := checkExpectedRevision(expectedRevision, current, found); err != nil {
|
|
return InstanceStateEntry{}, err
|
|
}
|
|
now := time.Now().UTC()
|
|
if !found {
|
|
current = InstanceStateEntry{
|
|
InstanceID: instanceID,
|
|
Key: key,
|
|
Value: append(json.RawMessage(nil), value...),
|
|
Revision: 1,
|
|
WriterKind: writerKind,
|
|
UpdatedAt: now,
|
|
}
|
|
_, err = tx.ExecContext(ctx, tx.Rebind(`
|
|
INSERT INTO plugin_instance_state
|
|
(id, plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
|
`), uuid.New().String(), instanceID, key, string(value), current.Revision, writerKind, now.Format(time.RFC3339Nano))
|
|
} else {
|
|
current.Value = append(json.RawMessage(nil), value...)
|
|
current.Revision++
|
|
current.WriterKind = writerKind
|
|
current.UpdatedAt = now
|
|
current.Deleted = false
|
|
_, err = tx.ExecContext(ctx, tx.Rebind(`
|
|
UPDATE plugin_instance_state
|
|
SET value_json = ?, revision = ?, writer_kind = ?, updated_at = ?, deleted = 0
|
|
WHERE plugin_instance_id = ? AND state_key = ?
|
|
`), string(value), current.Revision, writerKind, now.Format(time.RFC3339Nano), instanceID, key)
|
|
}
|
|
if err != nil {
|
|
return InstanceStateEntry{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return InstanceStateEntry{}, err
|
|
}
|
|
return current, nil
|
|
}
|
|
|
|
// Delete removes the visible value and advances its revision. The tombstone
|
|
// keeps the revision monotonic. Deleting an already-deleted key is idempotent.
|
|
func (s *InstanceStore) Delete(ctx context.Context, instanceID, key string, expectedRevision *int64, writerKind string) (int64, error) {
|
|
if instanceID == "" || key == "" {
|
|
return 0, errors.New("plugin instance state: instance id and key are required")
|
|
}
|
|
if writerKind == "" {
|
|
writerKind = "unknown"
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
current, found, err := selectInstanceState(ctx, tx, s.db.DriverName(), instanceID, key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := checkExpectedRevision(expectedRevision, current, found); err != nil {
|
|
return 0, err
|
|
}
|
|
if found && current.Deleted {
|
|
return current.Revision, tx.Commit()
|
|
}
|
|
now := time.Now().UTC()
|
|
if !found {
|
|
_, err = tx.ExecContext(ctx, tx.Rebind(`
|
|
INSERT INTO plugin_instance_state
|
|
(id, plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted)
|
|
VALUES (?, ?, ?, 'null', 1, ?, ?, 1)
|
|
`), uuid.New().String(), instanceID, key, writerKind, now.Format(time.RFC3339Nano))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return 1, nil
|
|
}
|
|
current.Revision++
|
|
_, err = tx.ExecContext(ctx, tx.Rebind(`
|
|
UPDATE plugin_instance_state
|
|
SET value_json = 'null', revision = ?, writer_kind = ?, updated_at = ?, deleted = 1
|
|
WHERE plugin_instance_id = ? AND state_key = ?
|
|
`), current.Revision, writerKind, now.Format(time.RFC3339Nano), instanceID, key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return current.Revision, nil
|
|
}
|
|
|
|
// DeleteInstance removes all state owned by an isolated web-app instance.
|
|
// Instance deletion is a lifecycle boundary, so unlike Delete it removes the
|
|
// complete key/tombstone set instead of retaining per-key revisions.
|
|
func (s *InstanceStore) DeleteInstance(ctx context.Context, instanceID string) error {
|
|
if strings.TrimSpace(instanceID) == "" {
|
|
return errors.New("plugin instance state: instance id is required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
_, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM plugin_instance_state WHERE plugin_instance_id = ?`), instanceID)
|
|
return err
|
|
}
|
|
|
|
func selectInstanceState(
|
|
ctx context.Context,
|
|
tx *sqlx.Tx,
|
|
driver, instanceID, key string,
|
|
) (InstanceStateEntry, bool, error) {
|
|
query := `
|
|
SELECT plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted
|
|
FROM plugin_instance_state
|
|
WHERE plugin_instance_id = ? AND state_key = ?
|
|
`
|
|
if dialect.IsPostgres(driver) {
|
|
query += " FOR UPDATE"
|
|
}
|
|
var entry InstanceStateEntry
|
|
var raw, updatedAt string
|
|
var deleted int
|
|
err := tx.QueryRowxContext(ctx, tx.Rebind(query), instanceID, key).Scan(
|
|
&entry.InstanceID, &entry.Key, &raw, &entry.Revision, &entry.WriterKind, &updatedAt, &deleted,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return InstanceStateEntry{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return InstanceStateEntry{}, false, err
|
|
}
|
|
entry.Value = json.RawMessage(raw)
|
|
entry.Deleted = deleted != 0
|
|
entry.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt)
|
|
if err != nil {
|
|
return InstanceStateEntry{}, false, fmt.Errorf("parse instance state updated_at: %w", err)
|
|
}
|
|
return entry, true, nil
|
|
}
|
|
|
|
func checkExpectedRevision(expected *int64, current InstanceStateEntry, found bool) error {
|
|
if expected == nil {
|
|
return nil
|
|
}
|
|
currentRevision := int64(0)
|
|
if found {
|
|
currentRevision = current.Revision
|
|
}
|
|
if *expected != currentRevision {
|
|
return &ConflictError{CurrentRevision: currentRevision}
|
|
}
|
|
return nil
|
|
}
|