mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
104f809e67
* feat(store): enqueue push notifications at four store sites (TASK-3937 part B)
notifications.go writes queue rows on the caller's transaction, only for a
user with a registered device: item assignment (create and update),
comment and reply, fenced (app) comment, and reminder fire. The account
claim also deletes the old holder's devices, queue and settings.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* fix(store): comment recipients collected without a closure so the appstore boundary walk resolves them (TASK-3937 part B)
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* docs(store): with the enqueue built, the Go comments say it exists (TASK-3937 part B)
A (07dc06285) future-tensed every sentence about the enqueue, because
part B had not merged. In part B's tree they read as false, so the
current description lives in Go comments:
- notifications.go's header: rows only for a user with a registered
device, so nothing is written until TASK-3938; idempotent per
(user, dedupe_key).
- migrated.go's push note.
- the settings model.
- nulExcluded's contract header.
Migration 155 is NOT edited: merged migrations are immutable, comments
included, so its owed-tense prose stays as history, and the
notifications.go header says which description is current.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* docs(store): the enqueue says recipients are gated at send, and mentions are TASK-3946's (TASK-3937 part B r1)
Ruled on part B r1. notifications.go's header now says recipients are
not access-checked at enqueue: a removed member stays created_by and
possibly assignee, and the dispatcher (TASK-3939) is to re-check
membership and visibility at send. It also says the 'mention' kind is
enqueued by TASK-3946, from comment_mentions, not here.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
815 lines
37 KiB
Go
815 lines
37 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/kernelevents"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Item reminders — the fire-at-an-instant primitive (IDEA-2641, GitHub #1010).
|
|
//
|
|
// See migration 085 for why this is a table rather than an annotation on a
|
|
// schema field, and models.Reminder for the three-state lifecycle.
|
|
|
|
// reminderFireable is the single definition of "this reminder may fire",
|
|
// referenced by BOTH the candidate scan and the fire UPDATE's arbiter.
|
|
//
|
|
// ONE STRING, because the drift between those two is a defect class this unit
|
|
// hit three times: the scan filtered something the arbiter did not revalidate,
|
|
// so a change committed between them fired a reminder that no longer
|
|
// qualified. Round 3 was a re-armed instant, round 7 a workspace deleted
|
|
// mid-pass, and the round-1 soft-deleted item was the same shape caught from
|
|
// the other side. Each was fixed as an instance; this is the shape.
|
|
//
|
|
// Written as a correlated EXISTS on item_reminders.item_id — rather than as a
|
|
// JOIN — precisely so the identical text is valid in a SELECT and in an
|
|
// UPDATE. The scan deliberately does NOT alias item_reminders, so the two uses
|
|
// are the same characters and a new condition is one edit in one place.
|
|
//
|
|
// The two predicates that are NOT here (`fired_at IS NULL`, `remind_at <= ?`)
|
|
// are the ones that live on the reminder row itself and are already spelled
|
|
// identically at both sites; folding them in would need a parameter order this
|
|
// shared form cannot fix.
|
|
//
|
|
// THE ROW'S OWN workspace_id MUST AGREE WITH ITS ITEM'S (codex round 13).
|
|
// CreateReminder writes the pair from the item, and import writes it from
|
|
// its own in-workspace mapping, so no door produces a disagreement today, and
|
|
// since IDEA-2883 the TABLE forbids one: the foreign key is composite,
|
|
// (item_id, workspace_id) REFERENCES items(id, workspace_id), so a
|
|
// disagreeing row is unrepresentable rather than merely unwritten
|
|
// (migrations 086 / 063). The sentence that stood here said the table had "no
|
|
// constraint tying the two columns", which is what this predicate was
|
|
// compensating for.
|
|
//
|
|
// THE PREDICATE STAYS, and not out of habit. Enforcement binds to the
|
|
// connection doing the write, and SQLite's is a per-connection pragma that
|
|
// table-rebuild migrations legitimately turn off — so a row can still arrive
|
|
// from a restored pre-086 backup, from an operator who dropped the
|
|
// constraint, or through a future rebuild's window. When one does, the scan,
|
|
// the arbiter, the pin and the reads all refuse it here rather than one of
|
|
// them deciding it is "unreachable" on the others' behalf. Belt on top of
|
|
// braces, with the braces now actually present.
|
|
const reminderFireable = `EXISTS (
|
|
SELECT 1 FROM items i
|
|
JOIN workspaces w ON w.id = i.workspace_id
|
|
WHERE i.id = item_reminders.item_id
|
|
AND i.workspace_id = item_reminders.workspace_id
|
|
AND i.deleted_at IS NULL
|
|
AND w.deleted_at IS NULL
|
|
)`
|
|
|
|
const reminderColumns = `id, workspace_id, item_id, remind_at, fired_at, acked_at, created_at, updated_at`
|
|
|
|
// reminderOwned is the identity half of reminderFireable on its own — "this
|
|
// row's workspace is its item's workspace" — for the reads that do not care
|
|
// about liveness (a fired reminder on an archived item is still history worth
|
|
// showing) but must still refuse a row whose two columns disagree. Referenced
|
|
// by GetReminder and ListRemindersForItem (codex round 14); the write paths
|
|
// reach a row only through GetReminder, so scoping it scopes them. A row no
|
|
// door can write needs no door to delete it.
|
|
const reminderOwned = `EXISTS (
|
|
SELECT 1 FROM items i
|
|
WHERE i.id = item_reminders.item_id
|
|
AND i.workspace_id = item_reminders.workspace_id
|
|
)`
|
|
|
|
// defaultReminderFireLimit bounds one tick's work. Reminders arrive at a rate
|
|
// set by users arming them, not by traffic, so a tick has no reason to be
|
|
// large — but a backlog is possible after downtime, and an unbounded pass
|
|
// would try to fire every overdue reminder in one transaction storm. The
|
|
// remainder is not lost: it is still armed, and the next tick takes the next
|
|
// batch, oldest first.
|
|
const defaultReminderFireLimit = 100
|
|
|
|
// defaultPendingReminderLimit bounds the poll surface's window.
|
|
//
|
|
// THE RECEIPT: this is a NOTIFICATION list a human or an agent reads at a
|
|
// glance, not a queue to drain, so the bound is set by what is worth showing
|
|
// rather than by what the database can return. Fifty unacknowledged reminders
|
|
// already means the surface is not being used as intended; showing five
|
|
// hundred would not help, and the payload is embedded in every dashboard
|
|
// response, which is the hottest read in the product. The truncation is
|
|
// REPORTED rather than silent, so a caller that genuinely has more can tell.
|
|
const defaultPendingReminderLimit = 50
|
|
|
|
func scanReminder(row interface{ Scan(...any) error }) (*models.Reminder, error) {
|
|
var r models.Reminder
|
|
var firedAt, ackedAt sql.NullString
|
|
if err := row.Scan(&r.ID, &r.WorkspaceID, &r.ItemID, &r.RemindAt, &firedAt, &ackedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if firedAt.Valid {
|
|
r.FiredAt = &firedAt.String
|
|
}
|
|
if ackedAt.Valid {
|
|
r.AckedAt = &ackedAt.String
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// normalizeRemindAt re-parses and re-formats an instant, and REFUSES anything
|
|
// that is not RFC3339.
|
|
//
|
|
// The HTTP edge already normalizes, so on that path this is a second check of
|
|
// a value that is already correct — and it is here anyway, because the
|
|
// alternative was a doc comment saying "the caller normalizes", which protects
|
|
// nothing: the store is callable from anywhere in the process and a doc
|
|
// comment does not travel with the argument. Enforcing it means the stored
|
|
// value is always machine-produced from a parsed time, so no caller bytes
|
|
// reach the column at all. That is what lets remind_at sit outside the NUL
|
|
// census's protected set on a positive argument rather than an assumption.
|
|
func normalizeRemindAt(remindAt string) (string, error) {
|
|
t, err := time.Parse(time.RFC3339, remindAt)
|
|
if err != nil {
|
|
return "", fmt.Errorf("remind_at must be an RFC3339 instant: %w", err)
|
|
}
|
|
return NormalizeInstant(t), nil
|
|
}
|
|
|
|
// ErrReminderItemGone is CreateReminder's answer when the item is not a live
|
|
// item of the given workspace. Missing, soft-deleted, and belonging to another
|
|
// workspace are indistinguishable on purpose: telling them apart would make the
|
|
// store answer "does this item id exist somewhere on the instance", which is
|
|
// the existence-oracle shape GetReminder already refuses to be.
|
|
var ErrReminderItemGone = errors.New("reminder item is not live in this workspace")
|
|
|
|
// CreateReminder arms a reminder on an item.
|
|
//
|
|
// THE ITEM MUST BE A LIVE ITEM OF THIS WORKSPACE, and that is asserted by the
|
|
// INSERT itself rather than by a read before it (codex round 12). The table
|
|
// has a foreign key to items but no same-workspace constraint, so a plain
|
|
// INSERT accepts a (workspace, item) pair that names another workspace's item
|
|
// — and every reader then scopes by r.workspace_id and joins the item, which
|
|
// hands the first workspace's dashboard and webhooks the second one's title.
|
|
// The HTTP door resolves the item inside the workspace before calling here;
|
|
// the store refuses regardless, because a door is not the only caller the
|
|
// process can grow and a doc comment does not travel with the argument (the
|
|
// same argument normalizeRemindAt makes one function up).
|
|
//
|
|
// Liveness is part of the same predicate: arming a reminder on an archived
|
|
// item would write a row the scan filters out forever, which reads to the
|
|
// caller as a reminder that silently never fires.
|
|
func (s *Store) CreateReminder(workspaceID, itemID, remindAt string) (*models.Reminder, error) {
|
|
remindAt, err := normalizeRemindAt(remindAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id := newID()
|
|
ts := now()
|
|
res, err := s.db.Exec(s.q(`
|
|
INSERT INTO item_reminders (id, workspace_id, item_id, remind_at, fired_at, acked_at, created_at, updated_at)
|
|
SELECT ?, i.workspace_id, i.id, ?, NULL, NULL, ?, ?
|
|
FROM items i
|
|
WHERE i.id = ? AND i.workspace_id = ? AND i.deleted_at IS NULL
|
|
`), id, remindAt, ts, ts, itemID, workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create reminder: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create reminder: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return nil, ErrReminderItemGone
|
|
}
|
|
return s.GetReminder(workspaceID, id)
|
|
}
|
|
|
|
// GetReminder returns one reminder scoped to a workspace, or (nil, nil) when
|
|
// no such row exists.
|
|
//
|
|
// WORKSPACE-SCOPED ON PURPOSE, even though the id is a UUID and collisions are
|
|
// not the concern: an unscoped lookup would answer "does this id exist" for
|
|
// every workspace on the instance, which is the existence-oracle shape a
|
|
// sibling handler family already had to be fixed for. The caller has the
|
|
// workspace; requiring it costs nothing.
|
|
func (s *Store) GetReminder(workspaceID, id string) (*models.Reminder, error) {
|
|
row := s.db.QueryRow(s.q(`SELECT `+reminderColumns+` FROM item_reminders WHERE id = ? AND workspace_id = ? AND `+reminderOwned), id, workspaceID)
|
|
r, err := scanReminder(row)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get reminder: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// ListRemindersForItem returns every reminder on an item, armed or not,
|
|
// soonest first. History is included because a fired reminder is the record
|
|
// that a reminder existed and went out.
|
|
//
|
|
// Takes the workspace as well as the item (codex round 14): the caller has
|
|
// already resolved the item inside the workspace, so the argument costs
|
|
// nothing, and it lets the query refuse a row stamped with a different
|
|
// workspace than the item it points at — the same identity every other read
|
|
// asserts, rather than the one read that trusted the item_id alone.
|
|
func (s *Store) ListRemindersForItem(workspaceID, itemID string) ([]*models.Reminder, error) {
|
|
rows, err := s.db.Query(s.q(`SELECT `+reminderColumns+` FROM item_reminders WHERE item_id = ? AND workspace_id = ? AND `+reminderOwned+` ORDER BY remind_at, id`), itemID, workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list reminders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*models.Reminder
|
|
for rows.Next() {
|
|
r, err := scanReminder(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan reminder: %w", err)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate reminders: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// RearmReminder moves a reminder's instant and clears BOTH fire marks, so a
|
|
// reminder that already fired becomes armed again.
|
|
//
|
|
// The clear is unconditional rather than "only when fired_at is set" because
|
|
// the two cases must not diverge: on an armed row the marks are already NULL
|
|
// and the write is a no-op, and making it conditional would create a path
|
|
// where a re-arm leaves a stale acked_at behind on a row that is armed —
|
|
// a state models.Reminder's lifecycle does not have a name for.
|
|
func (s *Store) RearmReminder(workspaceID, id, remindAt string) (*models.Reminder, error) {
|
|
remindAt, err := normalizeRemindAt(remindAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := s.db.Exec(s.q(`
|
|
UPDATE item_reminders
|
|
SET remind_at = ?, fired_at = NULL, acked_at = NULL, updated_at = ?
|
|
WHERE id = ? AND workspace_id = ?
|
|
`), remindAt, now(), id, workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rearm reminder: %w", err)
|
|
}
|
|
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
|
return nil, nil
|
|
}
|
|
return s.GetReminder(workspaceID, id)
|
|
}
|
|
|
|
// AckReminder acknowledges a FIRED reminder.
|
|
//
|
|
// The `fired_at IS NOT NULL` predicate is what makes acking an armed reminder
|
|
// impossible rather than merely discouraged: an acked-but-never-fired row
|
|
// would sit in a state the lifecycle has no name for, and it would be
|
|
// invisible — the poll surface reads fired-unacked, so the row would simply
|
|
// never appear again.
|
|
//
|
|
// THE STATEMENT MATCHES EVERY FIRED ROW, acknowledged or not, so that a
|
|
// non-match means exactly one thing: at the instant of the ack, the reminder
|
|
// had not fired (or does not exist, which the handler tells apart by
|
|
// re-reading). The previous form also excluded already-acked rows, which left
|
|
// a no-match ambiguous — "too early" and "already done" need opposite
|
|
// reactions from a caller — and the handler resolved the ambiguity from a row
|
|
// it had read BEFORE the ack. A fire or re-arm landing between that read and
|
|
// the UPDATE made it answer 409 for a reminder it had just acknowledged, or
|
|
// 200 for one it had not (codex round 12). Folding the distinction into the
|
|
// statement removes the read the race needed.
|
|
//
|
|
// IDEMPOTENT by construction: COALESCE keeps the first acknowledgement's
|
|
// instant, and updated_at moves only when acked_at does, so a second ack
|
|
// matches, returns the row, and rewrites nothing.
|
|
func (s *Store) AckReminder(workspaceID, id string) (*models.Reminder, error) {
|
|
ts := now()
|
|
res, err := s.db.Exec(s.q(`
|
|
UPDATE item_reminders
|
|
SET updated_at = CASE WHEN acked_at IS NULL THEN ? ELSE updated_at END,
|
|
acked_at = COALESCE(acked_at, ?)
|
|
WHERE id = ? AND workspace_id = ? AND fired_at IS NOT NULL
|
|
`), ts, ts, id, workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ack reminder: %w", err)
|
|
}
|
|
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
|
return nil, nil
|
|
}
|
|
return s.GetReminder(workspaceID, id)
|
|
}
|
|
|
|
// DeleteReminder removes a reminder outright. Disarming by deletion is the
|
|
// only disarm: there is no "cancelled" state, because a cancelled reminder and
|
|
// an absent one are indistinguishable to every surface that reads them.
|
|
func (s *Store) DeleteReminder(workspaceID, id string) (bool, error) {
|
|
res, err := s.db.Exec(s.q(`DELETE FROM item_reminders WHERE id = ? AND workspace_id = ?`), id, workspaceID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("delete reminder: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// ListPendingReminders returns a workspace's fired-and-unacked reminders,
|
|
// joined to the items they are about, soonest-fired first.
|
|
//
|
|
// This is the AGENT POLL SURFACE, and it is not optional. The outbox drain
|
|
// acks an event immediately when no webhook dispatcher is configured — the
|
|
// common self-hosted shape — so a webhook-only reminder would be a no-op on
|
|
// most installs. The row this query returns is the only thing that survives
|
|
// on those instances.
|
|
//
|
|
// Terminal-item filtering happens in the CALLER, not here, because terminality
|
|
// is schema-defined (a collection's terminal_options) and lives in JSON the
|
|
// SQL layer would have to parse. The caller already builds that context for
|
|
// the dashboard; ItemFields and CollectionID are carried for it.
|
|
// PendingReminderScope narrows the query to what a caller may see. Nil
|
|
// CollectionIDs means unrestricted; a NON-NIL EMPTY slice with no ItemIDs
|
|
// means nothing is visible, matching models.ItemListParams so the two
|
|
// visibility paths cannot drift into opposite readings of the same value.
|
|
type PendingReminderScope struct {
|
|
CollectionIDs []string
|
|
ItemIDs []string
|
|
}
|
|
|
|
// The window is BOUNDED because this list feeds a payload that is otherwise
|
|
// capped: every pending reminder became a suggestion prepended to a
|
|
// three-entry list, so a workspace with five hundred unacknowledged reminders
|
|
// returned five hundred suggestions and grew without limit until somebody
|
|
// acknowledged them (codex round 3). Oldest-fired first, so the window holds
|
|
// the reminders that have been waiting longest rather than an arbitrary slice.
|
|
//
|
|
// The caller is told when the window was not the whole set — but as a BOOLEAN,
|
|
// not a count. A count would have to be stated post-visibility-filter to be
|
|
// true for the caller reading it, and this query cannot compute that: the
|
|
// filter runs above, per item. "There are more than you can see here" is the
|
|
// strongest claim the data supports, so it is the one made.
|
|
// VISIBILITY IS SCOPED IN SQL, not filtered afterwards, and that is the
|
|
// round-4 correction. The round-3 bound took the first N rows and let the
|
|
// caller discard the ones it could not show — which recreated, in the READ
|
|
// path, the exact starvation the round-1 fix removed from the FIRE path:
|
|
// fifty rows the caller must drop can hide a visible reminder behind them
|
|
// forever, with no continuation to reach it. A bounded window is only safe
|
|
// when the discarding happens BEFORE the bound.
|
|
//
|
|
// One filter necessarily stays above: terminality is defined by a collection's
|
|
// schema, which SQL cannot read. That one is handled by paging (the caller
|
|
// asks for the next page when a page comes back short), which is why this
|
|
// takes an offset at all.
|
|
func (s *Store) ListPendingReminders(workspaceID string, scope PendingReminderScope, limit, offset int) ([]*models.PendingReminder, bool, error) {
|
|
if limit <= 0 {
|
|
limit = defaultPendingReminderLimit
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
// Nothing visible at all: answer without touching the database, and say
|
|
// there is no more — a truncation flag here would send the caller paging
|
|
// through a set it can never see into.
|
|
if scope.CollectionIDs != nil && len(scope.CollectionIDs) == 0 && len(scope.ItemIDs) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
query := `
|
|
SELECT r.id, r.workspace_id, r.item_id, r.remind_at, r.fired_at, r.acked_at, r.created_at, r.updated_at,
|
|
i.slug, i.title, i.fields, i.collection_id, c.slug, c.prefix, i.item_number
|
|
FROM item_reminders r
|
|
JOIN items i ON i.id = r.item_id AND i.workspace_id = r.workspace_id
|
|
JOIN collections c ON c.id = i.collection_id
|
|
JOIN workspaces w ON w.id = r.workspace_id
|
|
WHERE r.workspace_id = ?
|
|
AND r.fired_at IS NOT NULL
|
|
AND r.acked_at IS NULL
|
|
AND i.deleted_at IS NULL
|
|
AND w.deleted_at IS NULL`
|
|
args := []any{workspaceID}
|
|
|
|
// Same three-way shape as models.ItemListParams: a guest may hold
|
|
// collection-level grants, item-level grants, or both, and "both" is an OR
|
|
// rather than an AND — an item in a fully granted collection qualifies
|
|
// even when it is not individually granted.
|
|
switch {
|
|
case len(scope.CollectionIDs) > 0 && len(scope.ItemIDs) > 0:
|
|
query += " AND (i.collection_id IN (" + placeholders(len(scope.CollectionIDs)) +
|
|
") OR i.id IN (" + placeholders(len(scope.ItemIDs)) + "))"
|
|
for _, id := range scope.CollectionIDs {
|
|
args = append(args, id)
|
|
}
|
|
for _, id := range scope.ItemIDs {
|
|
args = append(args, id)
|
|
}
|
|
case len(scope.CollectionIDs) > 0:
|
|
query += " AND i.collection_id IN (" + placeholders(len(scope.CollectionIDs)) + ")"
|
|
for _, id := range scope.CollectionIDs {
|
|
args = append(args, id)
|
|
}
|
|
case len(scope.ItemIDs) > 0:
|
|
query += " AND i.id IN (" + placeholders(len(scope.ItemIDs)) + ")"
|
|
for _, id := range scope.ItemIDs {
|
|
args = append(args, id)
|
|
}
|
|
}
|
|
|
|
query += " ORDER BY r.fired_at, r.id LIMIT ? OFFSET ?"
|
|
args = append(args, limit+1, offset)
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("list pending reminders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*models.PendingReminder
|
|
for rows.Next() {
|
|
var p models.PendingReminder
|
|
var firedAt, ackedAt sql.NullString
|
|
var prefix string
|
|
// items.item_number IS NULLABLE (migration 006 added the column to
|
|
// existing rows), and scanning NULL into an int fails the Scan — which
|
|
// fails the query, which degrades the whole pending-reminder section
|
|
// and hides EVERY reminder in the workspace, not just the one legacy
|
|
// item's. ListWatchesForUser, which this query was modelled on, uses
|
|
// exactly this type; I copied its shape and dropped the part that
|
|
// handles the column's actual nullability.
|
|
var number sql.NullInt64
|
|
if err := rows.Scan(
|
|
&p.ID, &p.WorkspaceID, &p.ItemID, &p.RemindAt, &firedAt, &ackedAt, &p.CreatedAt, &p.UpdatedAt,
|
|
&p.ItemSlug, &p.ItemTitle, &p.ItemFields, &p.CollectionID, &p.CollectionSlug, &prefix, &number,
|
|
); err != nil {
|
|
return nil, false, fmt.Errorf("scan pending reminder: %w", err)
|
|
}
|
|
if firedAt.Valid {
|
|
p.FiredAt = &firedAt.String
|
|
}
|
|
if ackedAt.Valid {
|
|
p.AckedAt = &ackedAt.String
|
|
}
|
|
// No ref rather than a wrong one: "PREFIX-0" would name a different
|
|
// item, and every consumer of this list can render a title without a
|
|
// ref (same disposition as ListWatchesForUser).
|
|
if prefix != "" && number.Valid {
|
|
p.ItemRef = fmt.Sprintf("%s-%d", prefix, number.Int64)
|
|
}
|
|
out = append(out, &p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, fmt.Errorf("iterate pending reminders: %w", err)
|
|
}
|
|
// The extra row is the probe, never a result.
|
|
if len(out) > limit {
|
|
return out[:limit], true, nil
|
|
}
|
|
return out, false, nil
|
|
}
|
|
|
|
// dueReminderCandidates returns the ids of armed reminders whose instant has
|
|
// arrived, oldest first.
|
|
//
|
|
// Split from FireDueReminders so a test can drive the arbiter below with a
|
|
// deliberately STALE candidate list — the race this shape exists for. Through
|
|
// the public entry point that race is unobservable, because this query has
|
|
// already filtered the rows it is about to hand over. Same split, and the same
|
|
// reason, as the outbox claim's pendingClaimCandidates / claimOutboxIDs.
|
|
func (s *Store) dueReminderCandidates(nowTS string, limit int) ([]string, error) {
|
|
if limit <= 0 {
|
|
limit = defaultReminderFireLimit
|
|
}
|
|
// SOFT-DELETED ITEMS ARE EXCLUDED HERE, not merely skipped downstream
|
|
// (codex round 1). fireOneReminder rolls back when it finds the item gone,
|
|
// which leaves the reminder ARMED and therefore a candidate again on the
|
|
// next pass — so a batch bounded at `limit` and ordered oldest-first can be
|
|
// filled entirely by archived items, and no live reminder ever fires. The
|
|
// starvation is permanent and silent: the tick reports zero fired and looks
|
|
// idle. Filtering in the candidate query means those rows never occupy a
|
|
// slot, while the reminders themselves are kept, so restoring the item
|
|
// restores its reminder with it.
|
|
// THE WORKSPACE IS CHECKED TOO, not only the item (codex round 6).
|
|
// Workspace soft-delete deliberately leaves items in place for the 30-day
|
|
// restore window, so a workspace-level filter on the ITEM finds nothing
|
|
// wrong — and the tick kept firing, emitting outbound webhook events for a
|
|
// workspace whose owner had deleted it, possibly as part of deleting their
|
|
// account. That is the one failure mode here that reaches outside the
|
|
// process, which is why it outranks the starvation cases even though the
|
|
// SQL change is the same size.
|
|
//
|
|
// A restored workspace resumes normally: nothing is destroyed, the
|
|
// reminders simply stop being candidates while it is gone.
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT id FROM item_reminders
|
|
WHERE fired_at IS NULL AND remind_at <= ?
|
|
AND `+reminderFireable+`
|
|
ORDER BY remind_at, id
|
|
LIMIT ?
|
|
`), nowTS, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("due reminder candidates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("scan due reminder id: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate due reminders: %w", err)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// THE FIRE-PATH INVARIANT, stated once so the next change is measured against
|
|
// it rather than against the last bug:
|
|
//
|
|
// THE CANDIDATE SCAN IS A HINT AND MAY BE ASSUMED TO PROVE NOTHING. Every
|
|
// condition that made a row a candidate must be re-asserted inside the
|
|
// transaction that marks it fired, in the SAME statement that does the
|
|
// marking, so that checking and writing are one atomic act. A reminder may
|
|
// be marked fired, and its event emitted, only if at that instant: its
|
|
// fired_at is still NULL, its remind_at is still at or before the pass's
|
|
// nowTS, its item is still not soft-deleted, and its workspace is still not
|
|
// soft-deleted.
|
|
//
|
|
// The reason it is worded as "the scan proves nothing" rather than as a list:
|
|
// a list invites the next person to add a condition to the scan and stop. Four
|
|
// separate defects in this unit were exactly that — a filter added to the scan
|
|
// while the arbiter went on not knowing about it — and each was fixed as an
|
|
// instance until the third made the shape visible. reminderFireable exists so
|
|
// the two sites cannot spell the shared half differently; this paragraph
|
|
// exists so nobody adds a fifth condition to one of them alone.
|
|
//
|
|
// ITEM LIVENESS IS DEFENDED TWICE, and the pin cannot tell the two apart —
|
|
// stated because the first version of this paragraph claimed the item load was
|
|
// "for the payload, not for the check", and the mutation matrix falsified that
|
|
// in one run. Removing the item half of reminderFireable alone changes NO
|
|
// observable behaviour: the UPDATE then matches, the load returns nil for the
|
|
// soft-deleted item, and the deferred rollback undoes the write. So the
|
|
// invariant holds either way and a single-mutant experiment cannot say which
|
|
// guard is carrying it. Removing BOTH kills the test, which is the experiment
|
|
// that shows they are a genuine pair rather than one of them being dead.
|
|
//
|
|
// They are kept as a pair on purpose, and the predicate is the primary: it
|
|
// means the row never matches, so no write happens at all, where the load
|
|
// means a write happens and is undone. The load is needed regardless — the
|
|
// payload carries an item snapshot — so the redundancy costs nothing beyond
|
|
// this paragraph. Workspace liveness has no such second READ, which is why
|
|
// dropping ITS half of the predicate does fail the pin.
|
|
//
|
|
// A READ IS NOT A HOLD (codex round 12, found independently by two runs on
|
|
// the same line). Everything above re-asserts liveness at the instant the
|
|
// predicate is evaluated; nothing above keeps it true until the transaction
|
|
// commits. On SQLite that gap does not exist — the DSN's _txlock=immediate
|
|
// makes every db.Begin() a BEGIN IMMEDIATE, so an archival cannot even open
|
|
// its transaction while a fire is in flight. On Postgres under READ COMMITTED
|
|
// the UPDATE locks only the reminder row: DeleteItem or DeleteWorkspace can
|
|
// commit its deleted_at after the predicate passed and before the outbox
|
|
// write commits, and the event then leaves the process describing a resource
|
|
// that was archived before the event existed. fireOneReminder therefore pins
|
|
// the item and workspace rows (FOR NO KEY UPDATE) as its first statement on
|
|
// Postgres, so the archival waits for the fire to commit — delayed, never
|
|
// lost — or, having committed first, makes the pin's re-read miss and the
|
|
// fire return without emitting. "At that instant" in the invariant means the
|
|
// commit instant, and the pin is what makes the predicate's instant and the
|
|
// commit instant the same one.
|
|
//
|
|
// Emission happens after the predicate passed and inside the same transaction,
|
|
// so an event cannot describe a state that no longer held when it was written.
|
|
//
|
|
// FireDueReminders marks every arrived reminder as fired and writes its event,
|
|
// returning the reminders this pass actually fired.
|
|
//
|
|
// ONE TRANSACTION PER REMINDER, carrying both the fired_at write and the
|
|
// outbox insert. That pairing is the whole point and it is not an efficiency
|
|
// choice: a fired_at committed without its event is a reminder that silently
|
|
// never notifies anyone and can never be retried, because the row has left the
|
|
// armed set. An event committed without fired_at fires again every tick. The
|
|
// transaction is what makes both unrepresentable — the same discipline the
|
|
// outbox itself exists to provide for ordinary mutations.
|
|
//
|
|
// Per-reminder rather than per-batch so one unfireable row (a deleted item
|
|
// racing the tick, a payload that will not marshal) cannot hold back every
|
|
// other reminder in the pass.
|
|
//
|
|
// The UPDATE re-checks the FULL candidate condition — the fire mark, the
|
|
// instant, and the shared reminderFireable predicate — so it arbitrates
|
|
// against every actor the scan filtered for — and getting only the first was the round-3
|
|
// defect. Against a concurrent TICK, `fired_at IS NULL` means both instances
|
|
// see the same candidate and exactly one gets RowsAffected 1; the loser does
|
|
// no work and emits nothing. Against a concurrent USER, `remind_at <= nowTS`
|
|
// means a reminder deferred between the scan and the fire is not fired — which
|
|
// the fire mark alone could not catch, because a re-arm CLEARS that mark.
|
|
//
|
|
// The distinction is worth keeping in view: an arbiter is only an arbiter with
|
|
// respect to the writers it can see, and this one was written with ticks in
|
|
// mind while a user edit went straight past it.
|
|
func (s *Store) FireDueReminders(nowTS string, limit int) ([]*models.Reminder, error) {
|
|
ids, err := s.dueReminderCandidates(nowTS, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// ONE FAILURE DOES NOT END THE PASS (codex round 1). The per-reminder
|
|
// transaction above exists precisely so that one unfireable row cannot
|
|
// hold back the rest — and returning on the first error made that comment
|
|
// false, since candidates are ordered oldest-first and a persistently
|
|
// broken old reminder would then block every newer one forever. The errors
|
|
// are collected rather than dropped: a pass that failed on three rows and
|
|
// fired seven must report both halves, or the tick's log reads like a
|
|
// clean pass.
|
|
return fireEachReminder(ids, nowTS, s.fireOneReminder)
|
|
}
|
|
|
|
// fireEachReminder is the pass's isolation property, split out so a test can
|
|
// inject a failing fire for one id and observe that the ids after it still
|
|
// run. Through the public entry point that is not reachable: making a real
|
|
// reminder fail mid-transaction requires corrupting a row the database
|
|
// refuses to store corrupt. Same split, and the same reason, as
|
|
// dueReminderCandidates / fireOneReminder.
|
|
func fireEachReminder(ids []string, nowTS string, fire func(id, nowTS string) (*models.Reminder, error)) ([]*models.Reminder, error) {
|
|
var fired []*models.Reminder
|
|
var errs []error
|
|
for _, id := range ids {
|
|
r, err := fire(id, nowTS)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
continue
|
|
}
|
|
if r != nil {
|
|
fired = append(fired, r)
|
|
}
|
|
}
|
|
return fired, errors.Join(errs...)
|
|
}
|
|
|
|
// fireOneReminder is the arbiter plus the emission, in one transaction.
|
|
// Returns (nil, nil) when another pass won the row or its item is gone.
|
|
func (s *Store) fireOneReminder(id, nowTS string) (*models.Reminder, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fire reminder: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// PIN THE ITEM AND WORKSPACE ROWS FOR THE REST OF THE TRANSACTION on
|
|
// Postgres (codex round 12). The predicate below READS liveness, and a read
|
|
// is not a hold: under READ COMMITTED the UPDATE locks only the reminder
|
|
// row, so DeleteItem / DeleteWorkspace can commit deleted_at between the
|
|
// predicate's evaluation and this transaction's commit, and the event goes
|
|
// out about a resource archived before the event existed — a webhook to a
|
|
// deleted workspace's endpoint is the one failure here that reaches outside
|
|
// the process. See the invariant paragraph on FireDueReminders.
|
|
//
|
|
// FOR NO KEY UPDATE, as CreateAttachmentForLiveItem: both archival UPDATEs
|
|
// touch no key column, so they take FOR NO KEY UPDATE and conflict with
|
|
// this holder — the archival blocks until the fire commits and then
|
|
// proceeds, delayed but never lost. In the other interleaving the archival
|
|
// commits first; the locked re-read is re-evaluated after the wait, no
|
|
// longer matches deleted_at IS NULL, and this call returns without firing.
|
|
// FK-share readers on the item (comments, the Yjs op-log) are not blocked.
|
|
// The workspace join goes through the ITEM's workspace_id, exactly as
|
|
// reminderFireable does, so the two cannot disagree about which row.
|
|
//
|
|
// SQLite skips the pin: its DSN sets _txlock=immediate, so db.Begin() is a
|
|
// BEGIN IMMEDIATE and writers already serialize — the interleaving is
|
|
// unrepresentable there, and the locking clause is a syntax error.
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
var pinned string
|
|
err := tx.QueryRow(s.q(`
|
|
SELECT i.id FROM item_reminders r
|
|
JOIN items i ON i.id = r.item_id AND i.workspace_id = r.workspace_id
|
|
JOIN workspaces w ON w.id = i.workspace_id
|
|
WHERE r.id = ? AND i.deleted_at IS NULL AND w.deleted_at IS NULL
|
|
FOR NO KEY UPDATE OF i, w
|
|
`), id).Scan(&pinned)
|
|
switch {
|
|
case err == sql.ErrNoRows:
|
|
// Archived, or gone, since the scan. Leave the reminder as it is
|
|
// and emit nothing — the same outcome the predicate produces.
|
|
return nil, nil
|
|
case err != nil:
|
|
return nil, fmt.Errorf("pin reminder %s item and workspace: %w", id, err)
|
|
}
|
|
}
|
|
|
|
// THE INSTANT IS REVALIDATED HERE, not just the fire mark (codex round 3).
|
|
// A re-arm can move this reminder into the future between the candidate
|
|
// scan and this UPDATE — it clears fired_at, so a predicate that checked
|
|
// only `fired_at IS NULL` still matched, and the pass fired a reminder the
|
|
// user had just deferred and emitted its event. The re-arm cannot undo
|
|
// that: it can clear the mark, but the event is already on the outbox.
|
|
//
|
|
// Same nowTS the candidate scan used, deliberately: the arbiter and the
|
|
// scan must agree about when this pass is, or a reminder could pass one
|
|
// and fail the other for no reason but clock drift within the pass.
|
|
res, err := tx.Exec(s.q(`
|
|
UPDATE item_reminders SET fired_at = ?, updated_at = ?
|
|
WHERE id = ? AND fired_at IS NULL AND remind_at <= ?
|
|
AND `+reminderFireable+`
|
|
`), nowTS, now(), id, nowTS)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fire reminder %s: %w", id, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fire reminder %s: %w", id, err)
|
|
}
|
|
if n == 0 {
|
|
// Another instance's tick won this row. Not an error, and emitting
|
|
// nothing is the correct outcome: the winner emits.
|
|
return nil, nil
|
|
}
|
|
|
|
row := tx.QueryRow(s.q(`SELECT `+reminderColumns+` FROM item_reminders WHERE id = ?`), id)
|
|
r, err := scanReminder(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reload fired reminder %s: %w", id, err)
|
|
}
|
|
|
|
// READ THE ITEM ON THE TRANSACTION, never on s.db. A pool read inside a
|
|
// transaction that holds a write lock deadlocks on a single-connection
|
|
// pool, which is exactly how this store is configured under SQLite.
|
|
item, err := s.GetItemQ(tx, r.ItemID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load item for reminder %s: %w", id, err)
|
|
}
|
|
if item == nil {
|
|
// The item was soft-deleted between the candidate scan and now.
|
|
// Roll the fire back rather than emitting an event about an item a
|
|
// consumer cannot fetch: the deferred Rollback does it, and the
|
|
// reminder stays armed. A hard delete cascades the row away instead.
|
|
return nil, nil
|
|
}
|
|
|
|
if err := s.emitReminderEventTx(tx, r, item); err != nil {
|
|
return nil, fmt.Errorf("emit reminder event %s: %w", id, err)
|
|
}
|
|
// TASK-3937: after the arbiter's UPDATE, so a pass that lost the row
|
|
// writes nothing.
|
|
if err := s.enqueueReminderNotificationTx(tx, r, item); err != nil {
|
|
return nil, fmt.Errorf("enqueue reminder notification %s: %w", id, err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("fire reminder %s: %w", id, err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// reminderEventPayload is the PayloadReminder shape: the reminder that fired,
|
|
// and the item it is about.
|
|
//
|
|
// The item is a scrubbed snapshot, matching every other item-carrying payload
|
|
// — a reminder event travels the same webhook wire as item.created and must
|
|
// not be the one door that ships PII the others strip.
|
|
type reminderEventPayload struct {
|
|
Reminder *models.Reminder `json:"reminder"`
|
|
Item *models.Item `json:"item"`
|
|
}
|
|
|
|
// emitReminderEventTx writes one item.reminder_due event on the caller's
|
|
// transaction.
|
|
func (s *Store) emitReminderEventTx(tx *sql.Tx, r *models.Reminder, item *models.Item) error {
|
|
if r == nil || item == nil {
|
|
return fmt.Errorf("outbox: %s has no reminder or item snapshot", kernelevents.ItemReminderDue)
|
|
}
|
|
payload, err := marshalEventPayload(reminderEventPayload{Reminder: r, Item: scrubItemPII(item)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeOutboxTx(tx, s, OutboxEvent{
|
|
WorkspaceID: r.WorkspaceID,
|
|
EventType: kernelevents.ItemReminderDue,
|
|
SubjectID: r.ID,
|
|
Payload: payload,
|
|
PayloadFamily: kernelevents.PayloadReminder,
|
|
})
|
|
}
|
|
|
|
// NormalizeInstant renders a parsed time as the RFC3339 second it must not
|
|
// fire before, in UTC.
|
|
//
|
|
// SECONDS ARE THE STORED RESOLUTION: the column is compared as a string
|
|
// against a whole-second clock, and the tick runs on a 30s interval, so
|
|
// sub-second precision is not a thing this system can honour. The question is
|
|
// only which way to resolve it, and truncating was wrong (codex round 2):
|
|
// `09:00:00.900Z` truncated to `09:00:00Z` fires 900ms BEFORE the moment the
|
|
// caller named, and it does so silently, having rewritten their value on the
|
|
// way in.
|
|
//
|
|
// Rounding UP costs at most a second of lateness and makes the guarantee
|
|
// stateable: a reminder never fires before the instant it was set for. Late is
|
|
// a reminder; early is a wrong answer.
|
|
//
|
|
// Whole seconds are unchanged, so the ordinary case round-trips exactly.
|
|
func NormalizeInstant(t time.Time) string {
|
|
u := t.UTC()
|
|
if trunc := u.Truncate(time.Second); !trunc.Equal(u) {
|
|
u = trunc.Add(time.Second)
|
|
}
|
|
return u.Format(time.RFC3339)
|
|
}
|