Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup. Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
201 lines
5.2 KiB
Go
201 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
const (
|
|
maximumCodeBytes = 200 * 1024
|
|
maximumLoadoutsBytes = 512 * 1024
|
|
maximumTitleRunes = 80
|
|
)
|
|
|
|
var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`)
|
|
|
|
type playground struct {
|
|
ID string `json:"id"`
|
|
Revision int `json:"revision"`
|
|
Title string `json:"title"`
|
|
Code string `json:"code"`
|
|
Loadouts json.RawMessage `json:"loadouts"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type playgroundStore struct {
|
|
*sql.DB
|
|
}
|
|
|
|
func openPlaygroundStore(path string) (*playgroundStore, error) {
|
|
if path != ":memory:" {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
database, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
database.SetMaxOpenConns(1)
|
|
store := &playgroundStore{DB: database}
|
|
if err := store.initialize(); err != nil {
|
|
database.Close()
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func (store *playgroundStore) initialize() error {
|
|
_, err := store.Exec(`
|
|
CREATE TABLE IF NOT EXISTS playgrounds (
|
|
id TEXT NOT NULL,
|
|
revision INTEGER NOT NULL CHECK (revision > 0),
|
|
title TEXT NOT NULL,
|
|
code TEXT NOT NULL,
|
|
loadouts TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (id, revision)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS playgrounds_created_at
|
|
ON playgrounds (created_at DESC);
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := store.Query(`PRAGMA table_info(playgrounds)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hasLoadouts := false
|
|
for rows.Next() {
|
|
var position, notNull, primaryKey int
|
|
var name, kind string
|
|
var defaultValue any
|
|
if err := rows.Scan(&position, &name, &kind, ¬Null, &defaultValue, &primaryKey); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
hasLoadouts = hasLoadouts || name == "loadouts"
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return err
|
|
}
|
|
if !hasLoadouts {
|
|
_, err = store.Exec(`ALTER TABLE playgrounds ADD COLUMN loadouts TEXT NOT NULL DEFAULT '[]'`)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (store *playgroundStore) save(ctx context.Context, id, title, code, loadouts string) (playground, error) {
|
|
if id != "" && !playgroundIDPattern.MatchString(id) {
|
|
return playground{}, errors.New("invalid playground id")
|
|
}
|
|
if title == "" || utf8.RuneCountInString(title) > maximumTitleRunes {
|
|
return playground{}, fmt.Errorf("title must contain 1 to %d characters", maximumTitleRunes)
|
|
}
|
|
if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) {
|
|
return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes)
|
|
}
|
|
if loadouts == "" {
|
|
loadouts = "[]"
|
|
}
|
|
if len(loadouts) > maximumLoadoutsBytes || !utf8.ValidString(loadouts) {
|
|
return playground{}, fmt.Errorf("loadouts must contain at most %d bytes of UTF-8", maximumLoadoutsBytes)
|
|
}
|
|
var loadoutList []json.RawMessage
|
|
if err := json.Unmarshal([]byte(loadouts), &loadoutList); err != nil || loadoutList == nil {
|
|
return playground{}, errors.New("loadouts must be a JSON array")
|
|
}
|
|
if id == "" {
|
|
var err error
|
|
id, err = randomPlaygroundID()
|
|
if err != nil {
|
|
return playground{}, err
|
|
}
|
|
}
|
|
|
|
transaction, err := store.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return playground{}, err
|
|
}
|
|
defer transaction.Rollback()
|
|
|
|
var revision int
|
|
if err := transaction.QueryRowContext(
|
|
ctx,
|
|
`SELECT COALESCE(MAX(revision), 0) + 1 FROM playgrounds WHERE id = ?`,
|
|
id,
|
|
).Scan(&revision); err != nil {
|
|
return playground{}, err
|
|
}
|
|
createdAt := time.Now().UTC().Truncate(time.Millisecond)
|
|
if _, err := transaction.ExecContext(
|
|
ctx,
|
|
`INSERT INTO playgrounds (id, revision, title, code, loadouts, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id,
|
|
revision,
|
|
title,
|
|
code,
|
|
loadouts,
|
|
createdAt.Format(time.RFC3339Nano),
|
|
); err != nil {
|
|
return playground{}, err
|
|
}
|
|
if err := transaction.Commit(); err != nil {
|
|
return playground{}, err
|
|
}
|
|
return playground{
|
|
ID: id, Revision: revision, Title: title, Code: code,
|
|
Loadouts: json.RawMessage(loadouts), CreatedAt: createdAt,
|
|
}, nil
|
|
}
|
|
|
|
func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) {
|
|
if !playgroundIDPattern.MatchString(id) || revision < 1 {
|
|
return playground{}, sql.ErrNoRows
|
|
}
|
|
var result playground
|
|
var createdAt, loadouts string
|
|
err := store.QueryRowContext(
|
|
ctx,
|
|
`SELECT id, revision, title, code, loadouts, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
|
|
id,
|
|
revision,
|
|
).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &loadouts, &createdAt)
|
|
if err != nil {
|
|
return playground{}, err
|
|
}
|
|
result.Loadouts = json.RawMessage(loadouts)
|
|
result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
|
|
return result, err
|
|
}
|
|
|
|
func randomPlaygroundID() (string, error) {
|
|
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"
|
|
result := make([]byte, 10)
|
|
for index := range result {
|
|
value, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
result[index] = alphabet[value.Int64()]
|
|
}
|
|
return string(result), nil
|
|
}
|