mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
dbbbefda75
* feat(store): record a delivery by id, unclaim without an attempt, re-check holds (TASK-3939 B1) RecordNotificationsSent is token-free for pending|expired rows (r4-L4), UnclaimNotifications hands never-tried rows back without an attempt, HeldNotifications is the pre-send re-check. The claim error drops the row id (Q8), notify.SendTimeout is exported (Q4), and the store doc says SENT is the one deliberate exception to settle-under-the-claim (Q5). Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * feat(server): the push dispatcher core and its privacy gates (TASK-3939 B1) Claims settled rows, runs membership, liveness, visibility, assignment, comment, settings, agent-activity and device gates on each (a skip carries a recorded reason and never reaches the gateway), groups survivors per (user, item), sends one content-free push per live device and settles per chunk. Lease >= sendTimeout + margin is enforced by the setter, the send deadline and a per-chunk time check. Not wired into cmd/pad. Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): dispatcher gates, lease invariant and late-send recording (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): an unknown notification kind is skipped (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * fix(server): the dispatcher logs no ids, settles zero-message units, checks the comment's item and has a clock seam (TASK-3939 B1) C1 reword the store header (the dispatcher is wired in part B2). H1 split computeWatchAccessVisibility into a log-free core the dispatcher calls; the handler's wrapper logs exactly as before. H2 settle a unit with no message as a no_device skip. H4 a mention/comment/reply row's comment must be on the row's item and workspace. Add the unexported now seam. T3 comment on the nil user branch. Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(store): settle primitives assert attempts, sent_at and span more than one id chunk (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(server): dispatcher fix round, clock-seam lease tests, every gate error, gate order, log leak, zero-message unit (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
75 lines
2.9 KiB
Go
75 lines
2.9 KiB
Go
package store
|
|
|
|
import "fmt"
|
|
|
|
// The dispatcher's settle primitives beyond push_dispatch.go's claim-guarded
|
|
// ones (TASK-3939 part B).
|
|
|
|
// RecordNotificationsSent records ids as pushed, BY ID AND NOT BY CLAIM: this
|
|
// is the one deliberate exception to "settle only under the claim" (r4-L4).
|
|
// A delivery is a fact. A claimer whose Send outlived its lease has still
|
|
// delivered, so its late 'sent' must land on a row another instance has since
|
|
// reclaimed (that instance's own settle is then a no-op) and on a row
|
|
// ExpireStaleNotifications took after the lease lapsed; a token guard would
|
|
// lose the delivered row or record it twice. Only pending and expired rows
|
|
// move: a skipped row stays skipped. Skip and release stay token-guarded.
|
|
func (s *Store) RecordNotificationsSent(ids []string) (int64, error) {
|
|
var total int64
|
|
for start := 0; start < len(ids); start += notificationIDChunk {
|
|
end := min(start+notificationIDChunk, len(ids))
|
|
ph, args := idPlaceholders(ids[start:end])
|
|
res, err := s.db.Exec(s.q(`UPDATE user_notifications
|
|
SET push_state = 'sent', sent_at = ?, claim_token = '', claimed_at = NULL
|
|
WHERE push_state IN ('pending', 'expired') AND id IN (`+ph+`)`), append([]any{now()}, args...)...)
|
|
if err != nil {
|
|
return total, fmt.Errorf("record notifications sent: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return total, fmt.Errorf("record notifications sent: %w", err)
|
|
}
|
|
total += n
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// UnclaimNotifications hands claimed rows back WITHOUT counting an attempt:
|
|
// for rows that were never tried (a shutdown, a deadline, a gate that could
|
|
// not be evaluated). token is Claim.Token; same guard as the other settles.
|
|
func (s *Store) UnclaimNotifications(token string, ids []string) (int64, error) {
|
|
return s.settleNotifications(token, ids, `claim_token = '', claimed_at = NULL`)
|
|
}
|
|
|
|
// HeldNotifications returns the subset of ids still pending under token: the
|
|
// dispatcher's check just before a Send that a row was not reclaimed,
|
|
// settled or deleted while it worked.
|
|
func (s *Store) HeldNotifications(token string, ids []string) ([]string, error) {
|
|
if token == "" {
|
|
return nil, errEmptyClaimToken
|
|
}
|
|
var held []string
|
|
for start := 0; start < len(ids); start += notificationIDChunk {
|
|
end := min(start+notificationIDChunk, len(ids))
|
|
ph, args := idPlaceholders(ids[start:end])
|
|
rows, err := s.db.Query(s.q(`SELECT id FROM user_notifications
|
|
WHERE claim_token = ? AND push_state = 'pending' AND id IN (`+ph+`)`), append([]any{token}, args...)...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("held notifications: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("held notifications: %w", err)
|
|
}
|
|
held = append(held, id)
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("held notifications: %w", err)
|
|
}
|
|
}
|
|
return held, nil
|
|
}
|