2026-08-04 16:33:22 +08:00
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const sqliteWriteTimeout = 2 * time . Second
type Store interface {
CreateDraft ( context . Context , Draft ) ( Draft , error )
ListDrafts ( context . Context ) ([] Draft , error )
2026-08-04 18:24:39 +08:00
ListTasks ( context . Context , TaskFilter ) ([] TaskRow , error )
StartPurchases ( context . Context , StartCommand , string ) ( StartResult , error )
2026-08-04 16:33:22 +08:00
}
type SQLiteStore struct {
2026-08-04 18:24:39 +08:00
database * sql . DB
now func () time . Time
writeGate chan struct {}
policy StartPolicy
2026-08-04 16:33:22 +08:00
}
func NewSQLiteStore ( database * sql . DB ) ( * SQLiteStore , error ) {
if database == nil {
return nil , errors . New ( "database is required" )
}
2026-08-04 18:24:39 +08:00
if _ , err := database . Exec ( "SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1" ); err != nil {
2026-08-04 16:33:22 +08:00
return nil , fmt . Errorf ( "tasks migration is not available: %w" , err )
}
2026-08-04 18:24:39 +08:00
if _ , err := database . Exec ( "SELECT 1 FROM purchase_attempts LIMIT 1" ); err != nil {
return nil , fmt . Errorf ( "single-pass migration is not available: %w" , err )
}
return & SQLiteStore { database : database , now : time . Now , writeGate : make ( chan struct {}, 1 )}, nil
2026-08-04 16:33:22 +08:00
}
func ( store * SQLiteStore ) CreateDraft ( ctx context . Context , draft Draft ) ( Draft , error ) {
writeContext , cancel := context . WithTimeout ( ctx , sqliteWriteTimeout )
defer cancel ()
// SQLite permits one writer at a time. Serializing this store's short create
// transaction prevents concurrent retries of one create key from surfacing as busy.
select {
2026-08-04 18:24:39 +08:00
case store . writeGate <- struct {}{}:
defer func () { <- store . writeGate }()
2026-08-04 16:33:22 +08:00
case <- writeContext . Done ():
return Draft {}, writeContext . Err ()
}
draft . CreatedAt = store . now (). UTC ()
transaction , err := store . database . BeginTx ( writeContext , nil )
if err != nil {
return Draft {}, err
}
defer transaction . Rollback ()
_ , err = transaction . ExecContext ( writeContext , `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)` , draft . ID , draft . Title , draft . GoodsID , draft . SKUColor , draft . SKUSize , draft . Quantity , draft . MaxTotalPrice , draft . CreatedAt . Format ( time . RFC3339Nano ), draft . CreatedAt . Format ( time . RFC3339Nano ))
if err == nil {
if err := transaction . Commit (); err != nil {
return Draft {}, err
}
return draft , nil
}
existing , found , currentPhase , lookupErr := findDraft ( writeContext , transaction , draft . ID )
if lookupErr != nil {
return Draft {}, lookupErr
}
if found && currentPhase && samePayload ( existing , draft ) {
if err := transaction . Commit (); err != nil {
return Draft {}, err
}
return existing , nil
}
if found {
return Draft {}, ErrCreateKeyConflict
}
return Draft {}, err
}
func ( store * SQLiteStore ) ListDrafts ( ctx context . Context ) ([] Draft , error ) {
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
// whereas UUID v4 is deliberately not time-sortable.
rows , err := store . database . QueryContext ( ctx , `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC` )
if err != nil {
return nil , err
}
defer rows . Close ()
result := [] Draft {}
for rows . Next () {
draft , err := scanDraft ( rows )
if err != nil {
return nil , err
}
result = append ( result , draft )
}
return result , rows . Err ()
}
func findDraft ( ctx context . Context , transaction * sql . Tx , id string ) ( Draft , bool , bool , error ) {
row := transaction . QueryRowContext ( ctx , `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?` , id )
var draft Draft
var created , source , status string
var version int
err := row . Scan ( & draft . ID , & draft . Title , & draft . GoodsID , & draft . SKUColor , & draft . SKUSize , & draft . Quantity , & draft . MaxTotalPrice , & created , & source , & status , & version )
if errors . Is ( err , sql . ErrNoRows ) {
return Draft {}, false , false , nil
}
if err != nil {
return Draft {}, false , false , err
}
parsed , err := time . Parse ( time . RFC3339Nano , created )
if err != nil {
return Draft {}, false , false , err
}
draft . CreatedAt = parsed
return draft , true , source == "MANUAL" && status == "DRAFT" && version == 1 , nil
}
type scanner interface { Scan ( ... any ) error }
func scanDraft ( row scanner ) ( Draft , error ) {
var draft Draft
var created string
if err := row . Scan ( & draft . ID , & draft . Title , & draft . GoodsID , & draft . SKUColor , & draft . SKUSize , & draft . Quantity , & draft . MaxTotalPrice , & created ); err != nil {
return Draft {}, err
}
parsed , err := time . Parse ( time . RFC3339Nano , created )
if err != nil {
return Draft {}, err
}
draft . CreatedAt = parsed
return draft , nil
}
func samePayload ( left , right Draft ) bool {
return left . ID == right . ID && left . Title == right . Title && left . GoodsID == right . GoodsID && left . SKUColor == right . SKUColor && left . SKUSize == right . SKUSize && left . Quantity == right . Quantity && left . MaxTotalPrice == right . MaxTotalPrice
}