chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
292
platform/internal/dbsync/apply.go
Normal file
292
platform/internal/dbsync/apply.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func PollOutbox(ctx context.Context, db *sql.DB, driver Driver, limit int) ([]OutboxRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
q := fmt.Sprintf(`SELECT id, table_name, row_pk, op, payload, version, created_at
|
||||
FROM %s WHERE synced_at IS NULL ORDER BY id ASC LIMIT %d`, OutboxTable, limit)
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OutboxRow
|
||||
for rows.Next() {
|
||||
var r OutboxRow
|
||||
var created any
|
||||
if err := rows.Scan(&r.ID, &r.TableName, &r.RowPK, &r.Op, &r.Payload, &r.Version, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch v := created.(type) {
|
||||
case time.Time:
|
||||
r.CreatedAt = v
|
||||
case string:
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", v)
|
||||
r.CreatedAt = t
|
||||
case []byte:
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", string(v))
|
||||
r.CreatedAt = t
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func MarkSynced(ctx context.Context, db *sql.DB, driver Driver, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
nowExpr := "CURRENT_TIMESTAMP"
|
||||
switch driver {
|
||||
case DriverSQLite:
|
||||
nowExpr = "datetime('now')"
|
||||
case DriverMySQL:
|
||||
nowExpr = "UTC_TIMESTAMP(3)"
|
||||
case DriverPostgres:
|
||||
nowExpr = "now()"
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
if driver == DriverPostgres {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
args[i] = id
|
||||
}
|
||||
q := fmt.Sprintf(`UPDATE %s SET synced_at = %s WHERE id IN (%s)`, OutboxTable, nowExpr, strings.Join(placeholders, ","))
|
||||
_, err := db.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func FetchRowJSON(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, pkVal string) (string, map[string]any, error) {
|
||||
q := fmt.Sprintf(`SELECT * FROM %s WHERE %s = ? LIMIT 1`, quoteIdent(driver, table), quoteIdent(driver, pkCol))
|
||||
if driver == DriverPostgres {
|
||||
q = fmt.Sprintf(`SELECT * FROM %s WHERE %s = $1 LIMIT 1`, quoteIdent(driver, table), quoteIdent(driver, pkCol))
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, q, pkVal)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !rows.Next() {
|
||||
return "", nil, sql.ErrNoRows
|
||||
}
|
||||
raw := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range raw {
|
||||
ptrs[i] = &raw[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
m := map[string]any{}
|
||||
for i, c := range cols {
|
||||
m[c] = normalizeValue(raw[i])
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return string(b), m, nil
|
||||
}
|
||||
|
||||
func normalizeValue(v any) any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []byte:
|
||||
return string(x)
|
||||
case time.Time:
|
||||
return x.UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
func GetMetaVersion(ctx context.Context, db *sql.DB, driver Driver, table, pk string) (int64, bool, error) {
|
||||
q := fmt.Sprintf(`SELECT version FROM %s WHERE table_name = ? AND row_pk = ?`, MetaTable)
|
||||
if driver == DriverPostgres {
|
||||
q = fmt.Sprintf(`SELECT version FROM %s WHERE table_name = $1 AND row_pk = $2`, MetaTable)
|
||||
}
|
||||
var ver int64
|
||||
err := db.QueryRowContext(ctx, q, table, pk).Scan(&ver)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return ver, true, nil
|
||||
}
|
||||
|
||||
func UpsertMeta(ctx context.Context, db *sql.DB, driver Driver, table, pk string, version int64) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
switch driver {
|
||||
case DriverSQLite:
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO `+MetaTable+`(table_name,row_pk,version,updated_at) VALUES(?,?,?,?)
|
||||
ON CONFLICT(table_name,row_pk) DO UPDATE SET version=excluded.version, updated_at=excluded.updated_at`,
|
||||
table, pk, version, now)
|
||||
return err
|
||||
case DriverMySQL:
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO `+MetaTable+`(table_name,row_pk,version,updated_at) VALUES(?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE version=VALUES(version), updated_at=VALUES(updated_at)`,
|
||||
table, pk, version, now)
|
||||
return err
|
||||
case DriverPostgres:
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO `+MetaTable+`(table_name,row_pk,version,updated_at) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(table_name,row_pk) DO UPDATE SET version=EXCLUDED.version, updated_at=EXCLUDED.updated_at`,
|
||||
table, pk, version, time.Now().UTC())
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyChange(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, op, payload string, version int64) error {
|
||||
return WithApplying(ctx, db, driver, func() error {
|
||||
return applyChangeInner(ctx, db, driver, table, pkCol, op, payload, version)
|
||||
})
|
||||
}
|
||||
|
||||
func applyChangeInner(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, op, payload string, version int64) error {
|
||||
switch op {
|
||||
case "delete":
|
||||
var row map[string]any
|
||||
_ = json.Unmarshal([]byte(payload), &row)
|
||||
pkVal := ""
|
||||
if row != nil {
|
||||
if v, ok := row[pkCol]; ok {
|
||||
pkVal = fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
if pkVal == "" {
|
||||
return fmt.Errorf("delete missing pk")
|
||||
}
|
||||
q := fmt.Sprintf(`DELETE FROM %s WHERE %s = ?`, quoteIdent(driver, table), quoteIdent(driver, pkCol))
|
||||
if driver == DriverPostgres {
|
||||
q = fmt.Sprintf(`DELETE FROM %s WHERE %s = $1`, quoteIdent(driver, table), quoteIdent(driver, pkCol))
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, q, pkVal); err != nil {
|
||||
return err
|
||||
}
|
||||
return UpsertMeta(ctx, db, driver, table, pkVal, version)
|
||||
default: // upsert
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &row); err != nil {
|
||||
return err
|
||||
}
|
||||
pkVal := fmt.Sprint(row[pkCol])
|
||||
if pkVal == "" || pkVal == "<nil>" {
|
||||
return fmt.Errorf("upsert missing pk %s", pkCol)
|
||||
}
|
||||
cols := make([]string, 0, len(row))
|
||||
vals := make([]any, 0, len(row))
|
||||
for k, v := range row {
|
||||
cols = append(cols, k)
|
||||
vals = append(vals, v)
|
||||
}
|
||||
return upsertRow(ctx, db, driver, table, pkCol, cols, vals, version)
|
||||
}
|
||||
}
|
||||
|
||||
func upsertRow(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string, cols []string, vals []any, version int64) error {
|
||||
qcols := make([]string, len(cols))
|
||||
ph := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
qcols[i] = quoteIdent(driver, c)
|
||||
if driver == DriverPostgres {
|
||||
ph[i] = fmt.Sprintf("$%d", i+1)
|
||||
} else {
|
||||
ph[i] = "?"
|
||||
}
|
||||
}
|
||||
pkVal := ""
|
||||
for i, c := range cols {
|
||||
if c == pkCol {
|
||||
pkVal = fmt.Sprint(vals[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
switch driver {
|
||||
case DriverSQLite:
|
||||
q := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s) ON CONFLICT(%s) DO UPDATE SET %s`,
|
||||
quoteIdent(driver, table),
|
||||
strings.Join(qcols, ","),
|
||||
strings.Join(ph, ","),
|
||||
quoteIdent(driver, pkCol),
|
||||
sqliteSetClause(cols, pkCol, driver),
|
||||
)
|
||||
if _, err := db.ExecContext(ctx, q, vals...); err != nil {
|
||||
return err
|
||||
}
|
||||
case DriverMySQL:
|
||||
sets := make([]string, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
if c == pkCol {
|
||||
continue
|
||||
}
|
||||
qi := quoteIdent(driver, c)
|
||||
sets = append(sets, qi+"=VALUES("+qi+")")
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
sets = append(sets, quoteIdent(driver, pkCol)+"="+quoteIdent(driver, pkCol))
|
||||
}
|
||||
q := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s`,
|
||||
quoteIdent(driver, table), strings.Join(qcols, ","), strings.Join(ph, ","), strings.Join(sets, ","))
|
||||
if _, err := db.ExecContext(ctx, q, vals...); err != nil {
|
||||
return err
|
||||
}
|
||||
case DriverPostgres:
|
||||
sets := make([]string, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
if c == pkCol {
|
||||
continue
|
||||
}
|
||||
qi := quoteIdent(driver, c)
|
||||
sets = append(sets, qi+"=EXCLUDED."+qi)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
sets = append(sets, quoteIdent(driver, pkCol)+"="+quoteIdent(driver, pkCol))
|
||||
}
|
||||
q := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s`,
|
||||
quoteIdent(driver, table), strings.Join(qcols, ","), strings.Join(ph, ","),
|
||||
quoteIdent(driver, pkCol), strings.Join(sets, ","))
|
||||
if _, err := db.ExecContext(ctx, q, vals...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return UpsertMeta(ctx, db, driver, table, pkVal, version)
|
||||
}
|
||||
|
||||
func sqliteSetClause(cols []string, pkCol string, driver Driver) string {
|
||||
parts := make([]string, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
if c == pkCol {
|
||||
continue
|
||||
}
|
||||
qi := quoteIdent(driver, c)
|
||||
parts = append(parts, qi+"=excluded."+qi)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return quoteIdent(driver, pkCol) + "=" + quoteIdent(driver, pkCol)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
Reference in New Issue
Block a user