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) ListTasks(context.Context, TaskFilter) ([]TaskRow, error) StartPurchases(context.Context, StartCommand, string) (StartResult, error) } type SQLiteStore struct { database *sql.DB now func() time.Time writeGate chan struct{} policy StartPolicy } func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) { if database == nil { return nil, errors.New("database is required") } if _, err := database.Exec("SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1"); err != nil { return nil, fmt.Errorf("tasks migration is not available: %w", err) } 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 } func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) { // Validate again at the persistence boundary. HTTP form validation is not the only // caller, and a malformed row here would later make an authorized claim unencodable. if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 { return Draft{}, ErrInvalidDraft } 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 { case store.writeGate <- struct{}{}: defer func() { <-store.writeGate }() 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 }