CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
The pocketbase driver no longer embeds PocketBase: Warp keeps its data as PocketBase collections on a server of its own and reaches it only through the REST API, the way the postgres driver reaches PostgreSQL. database.connection_string is the server's URL, and Warp signs in as the superuser in WARP_PB_SUPERUSER_EMAIL / WARP_PB_SUPERUSER_PASSWORD (or a WARP_PB_SUPERUSER_FILE of the two). Every SQL table is a superuser-only collection of the same name and columns, created by warp migrate. Reads go through PocketBase's SQL endpoint with the SQL drivers' queries, via a read-only database/sql driver that inlines arguments, moves long ones into warp_params and pages past 1000 rows. Writes are record operations in batch requests under one write lock in the Warp process. Media is a protected file field in warp_media, local or in the S3 bucket Warp writes into PocketBase's settings. Search matches word prefixes with LIKE. The storage tests run against an in-process PocketBase and pass as on SQLite. Docker: the PocketBase stacks get a pocketbase service running tajniak81/pocketbase-docker-root (WARP_PB_IMAGE), with a pb-data volume and its admin UI on loopback; the AIO images run that PocketBase binary as a process beside the homeserver, making up a superuser when none is set. The .env examples, READMEs, docs and warp.yaml.example follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.2 KiB
Go
233 lines
8.2 KiB
Go
package pocketbase
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// DelayedEvents is the PocketBase implementation of
|
|
// storage.DelayedEventStore.
|
|
type DelayedEvents struct{ *base }
|
|
|
|
// delayedEventColumns reads state_key as NULL for a message event, as the
|
|
// SQL drivers store it.
|
|
const delayedEventColumns = `
|
|
delay_id, user_id, device_id, room_id, event_type, CASE WHEN has_state_key THEN state_key END, content,
|
|
delay_ms, running_since_ms, send_at_ms,
|
|
finalised_at_ms, outcome, reason, event_id, error`
|
|
|
|
// InsertDelayedEvent implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) InsertDelayedEvent(ctx context.Context, e storage.DelayedEvent) error {
|
|
stateKey, hasStateKey := "", false
|
|
if e.StateKey != nil {
|
|
stateKey, hasStateKey = *e.StateKey, true
|
|
}
|
|
err := s.write(ctx, func(t *tx) error {
|
|
t.put("delayed_events", rec{
|
|
"delay_id": e.DelayID, "user_id": e.UserID, "device_id": e.DeviceID, "room_id": e.RoomID,
|
|
"event_type": e.Type, "state_key": stateKey, "has_state_key": hasStateKey,
|
|
"content": string(e.Content), "delay_ms": e.DelayMS, "running_since_ms": e.RunningSinceMS,
|
|
"send_at_ms": e.SendAtMS, "finalised_at_ms": int64(0),
|
|
"outcome": "", "reason": "", "event_id": "", "error": "",
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("insert delayed event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DelayedEvent implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) DelayedEvent(ctx context.Context, userID, delayID string) (storage.DelayedEvent, error) {
|
|
return s.one(ctx, `SELECT`+delayedEventColumns+`
|
|
FROM delayed_events WHERE user_id = ? AND delay_id = ?`, userID, delayID)
|
|
}
|
|
|
|
// ScheduledDelayedEvent implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) ScheduledDelayedEvent(ctx context.Context, delayID string) (storage.DelayedEvent, error) {
|
|
return s.one(ctx, `SELECT`+delayedEventColumns+`
|
|
FROM delayed_events WHERE delay_id = ? AND finalised_at_ms = 0`, delayID)
|
|
}
|
|
|
|
func (s *DelayedEvents) one(ctx context.Context, query string, args ...any) (storage.DelayedEvent, error) {
|
|
events, err := s.list(ctx, query, args...)
|
|
if err != nil {
|
|
return storage.DelayedEvent{}, err
|
|
}
|
|
if len(events) == 0 {
|
|
return storage.DelayedEvent{}, storage.ErrNoSuchDelayedEvent
|
|
}
|
|
return events[0], nil
|
|
}
|
|
|
|
// DelayedEvents implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) DelayedEvents(ctx context.Context, userID string) ([]storage.DelayedEvent, error) {
|
|
return s.list(ctx, `SELECT`+delayedEventColumns+`
|
|
FROM delayed_events WHERE user_id = ?
|
|
ORDER BY finalised_at_ms = 0 DESC, send_at_ms ASC, finalised_at_ms DESC`, userID)
|
|
}
|
|
|
|
// CountScheduledDelayedEvents implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) CountScheduledDelayedEvents(ctx context.Context, userID string) (int, error) {
|
|
var n int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM delayed_events
|
|
WHERE user_id = ? AND finalised_at_ms = 0`, userID).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("count delayed events: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// RestartDelayedEvent implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) RestartDelayedEvent(ctx context.Context, delayID string, nowMS int64) error {
|
|
return s.write(ctx, func(t *tx) error {
|
|
var delay int64
|
|
err := t.queryRow(`SELECT delay_ms FROM delayed_events WHERE delay_id = ? AND finalised_at_ms = 0`, delayID).
|
|
Scan(&delay)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return storage.ErrNoSuchDelayedEvent
|
|
case err != nil:
|
|
return fmt.Errorf("restart delayed event: %w", err)
|
|
}
|
|
t.put("delayed_events", rec{"delay_id": delayID, "running_since_ms": nowMS, "send_at_ms": nowMS + delay})
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// FinaliseDelayedEvent implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) FinaliseDelayedEvent(ctx context.Context, e storage.DelayedEvent) error {
|
|
return s.write(ctx, func(t *tx) error {
|
|
n, err := t.setWhere("delayed_events", rec{
|
|
"finalised_at_ms": e.FinalisedAtMS, "outcome": e.Outcome, "reason": e.Reason,
|
|
"event_id": e.EventID, "error": string(e.Error),
|
|
}, `delay_id = ? AND finalised_at_ms = 0`, e.DelayID)
|
|
if err != nil {
|
|
return fmt.Errorf("finalise delayed event: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return storage.ErrNoSuchDelayedEvent
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// DueDelayedEvents implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) DueDelayedEvents(ctx context.Context, nowMS int64, limit int) ([]storage.DelayedEvent, error) {
|
|
return s.list(ctx, `SELECT`+delayedEventColumns+`
|
|
FROM delayed_events WHERE finalised_at_ms = 0 AND send_at_ms <= ?
|
|
ORDER BY send_at_ms LIMIT ?`, nowMS, limit)
|
|
}
|
|
|
|
// NextDelayedEventDue implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) NextDelayedEventDue(ctx context.Context) (int64, bool, error) {
|
|
var next sql.NullInt64
|
|
if err := s.db.QueryRowContext(ctx, `SELECT MIN(send_at_ms) FROM delayed_events
|
|
WHERE finalised_at_ms = 0`).Scan(&next); err != nil {
|
|
return 0, false, fmt.Errorf("next delayed event: %w", err)
|
|
}
|
|
return next.Int64, next.Valid, nil
|
|
}
|
|
|
|
// ScheduledDelayedStateEvents implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) ScheduledDelayedStateEvents(ctx context.Context, roomID, eventType, stateKey string) ([]storage.DelayedEvent, error) {
|
|
return s.list(ctx, `SELECT`+delayedEventColumns+`
|
|
FROM delayed_events
|
|
WHERE room_id = ? AND event_type = ? AND has_state_key AND state_key = ? AND finalised_at_ms = 0`,
|
|
roomID, eventType, stateKey)
|
|
}
|
|
|
|
// PurgeFinalisedDelayedEvents implements storage.DelayedEventStore.
|
|
func (s *DelayedEvents) PurgeFinalisedDelayedEvents(ctx context.Context, beforeMS int64) error {
|
|
err := s.write(ctx, func(t *tx) error {
|
|
_, err := t.delWhere("delayed_events", `finalised_at_ms <> 0 AND finalised_at_ms < ?`, beforeMS)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("purge delayed events: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DelayedEvents) list(ctx context.Context, query string, args ...any) ([]storage.DelayedEvent, error) {
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read delayed events: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var out []storage.DelayedEvent
|
|
for rows.Next() {
|
|
var (
|
|
e storage.DelayedEvent
|
|
stateKey sql.NullString
|
|
content, errorContent string
|
|
)
|
|
if err := rows.Scan(&e.DelayID, &e.UserID, &e.DeviceID, &e.RoomID, &e.Type, &stateKey, &content,
|
|
&e.DelayMS, &e.RunningSinceMS, &e.SendAtMS,
|
|
&e.FinalisedAtMS, &e.Outcome, &e.Reason, &e.EventID, &errorContent,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("read delayed event: %w", err)
|
|
}
|
|
if stateKey.Valid {
|
|
e.StateKey = &stateKey.String
|
|
}
|
|
e.Content = []byte(content)
|
|
if errorContent != "" {
|
|
e.Error = []byte(errorContent)
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// OpenIDTokens is the PocketBase implementation of storage.OpenIDTokenStore.
|
|
type OpenIDTokens struct{ *base }
|
|
|
|
// InsertOpenIDToken implements storage.OpenIDTokenStore.
|
|
func (s *OpenIDTokens) InsertOpenIDToken(ctx context.Context, tokenHash, userID string, expiresAtMS int64) error {
|
|
err := s.write(ctx, func(t *tx) error {
|
|
t.put("openid_tokens", rec{"token_hash": tokenHash, "user_id": userID, "expires_at_ms": expiresAtMS})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("insert OpenID token: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// OpenIDTokenUser implements storage.OpenIDTokenStore.
|
|
func (s *OpenIDTokens) OpenIDTokenUser(ctx context.Context, tokenHash string, nowMS int64) (string, error) {
|
|
var userID string
|
|
err := s.db.QueryRowContext(ctx, `SELECT user_id FROM openid_tokens
|
|
WHERE token_hash = ? AND expires_at_ms > ?`, tokenHash, nowMS).Scan(&userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", storage.ErrNoSuchOpenIDToken
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("read OpenID token: %w", err)
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
// PurgeOpenIDTokens implements storage.OpenIDTokenStore.
|
|
func (s *OpenIDTokens) PurgeOpenIDTokens(ctx context.Context, beforeMS int64) error {
|
|
err := s.write(ctx, func(t *tx) error {
|
|
_, err := t.delWhere("openid_tokens", `expires_at_ms < ?`, beforeMS)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("purge OpenID tokens: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
_ storage.DelayedEventStore = (*DelayedEvents)(nil)
|
|
_ storage.OpenIDTokenStore = (*OpenIDTokens)(nil)
|
|
)
|