mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
3a49a84908
* fix(activity): the feed and dashboard count only rows the caller may see (BUG-3687) The workspace activity feed applied collection and item-grant visibility in Go, after the SQL LIMIT, so a restricted member whose newest events were hidden got a short or empty page, and the web feed reads a short page as the end. The dashboard's recent activity read 30 rows and filtered them the same way, so the same run of hidden rows emptied it. ActivityListParams.Visibility carries the caller's scope (visible collections, full-access collections, granted items) into ListWorkspaceActivity, so LIMIT and the keyset cursor count visible rows. The feed's Go filter is gone, since the clause is that filter keyed on collection ID instead of slug. The dashboard keeps its filters, because its guest grant rule is narrower than the clause, and sets ExcludeWorkspaceLevel for a guest, whom it never shows workspace-level rows. The census of filter-after-LIMIT lists in internal/server found these two callers and no others; the table is on BUG-3687. Tests: a restricted member's feed under a run of 40 newer hidden rows (page sizes 30 and 2), an item grant narrowing its collection, and the dashboard for a restricted member and for a guest. Pre-fix code and mutants N1-N5 are recorded on BUG-3687. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(activity): the dashboard query skips rows of items that no longer exist (BUG-3687) Codex round 1 (Medium): rows naming an item that no longer exists pass the clause as workspace-level, but the dashboard drops them, so 30 of them could still fill its window. ExcludeUnresolvedItems drops them in the query; the dashboard sets it, and the feed keeps showing them as before. Tests: the dashboard under 40 newer rows of a missing item (with the feed still showing them), and a bearer-authed admin who is a restricted member (round 1 Low, coverage). Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(activity): an unscoped dashboard also skips rows of items that no longer exist (BUG-3687) Codex round 2 (Medium): the exclusion rode on the visibility scope, which a cookie-session admin does not have, so 30 rows of missing items could still fill that dashboard's window. ExcludeUnresolvedItems is now a top-level ActivityListParams flag, applied for every caller, and the dashboard always sets it. Test: a cookie-session admin's dashboard under 40 newer rows of a missing item. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
1151 lines
48 KiB
Go
1151 lines
48 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// ActivityDebounceCooldown is the window during which repeated "updated" actions
|
|
// on the same item by the same writer are coalesced into a single activity
|
|
// entry. "Same writer" is narrower than the account: see
|
|
// CreateActivityDebounced, which refuses to coalesce across actor kinds or
|
|
// agent names sharing one user_id (BUG-2763), or across sources (PLAN-2348).
|
|
const ActivityDebounceCooldown = 5 * time.Minute
|
|
|
|
// maxDebounceCandidates bounds how many recent rows CreateActivityDebounced
|
|
// examines when looking for the current writer's own row to extend.
|
|
//
|
|
// It is a scan bound, not a semantic one, and it exists because created_at has
|
|
// WHOLE-SECOND precision: several rows inside the window can carry the same
|
|
// timestamp, and ORDER BY created_at cannot order them — SQLite returns such a
|
|
// tie in a stable-looking order that Postgres does not promise. Reading only
|
|
// the top row would let a rapid human → agent → agent sequence land on the
|
|
// human's row, refuse the merge on identity, and split the agent's run across
|
|
// two rows (codex round 1 on this fix).
|
|
//
|
|
// The rows that can crowd out the writer's own are narrower than "everything
|
|
// in the window": the query already filters to one document, one account and
|
|
// one actor kind, so only rows carrying a DIFFERENT agent name compete. For a
|
|
// human write that is normally none at all (a human write stamps no name), and
|
|
// can only be a row written before this fix, when a merge could overlay an
|
|
// agent name onto a user-actor row. For an agent it is the other agent names
|
|
// on the same account touching the same item inside five minutes.
|
|
//
|
|
// Ten of those is already an implausible load, and the bound is deliberately
|
|
// not a correctness one: past it the loop fails to find the writer's own row
|
|
// and starts a new one, which costs an extra activity row and can never
|
|
// produce a wrong attribution — the direction this fix exists to prevent.
|
|
// Raising it costs a longer scan on a write path; the number is a scan
|
|
// ceiling, not a statement about how many rows the window may hold.
|
|
// (Declined codex round 2, which read the bound as incomplete coalescing.)
|
|
//
|
|
// WHAT IT DOES NOT BOUND (codex rounds 5-7 on BUG-2770). It caps the rows
|
|
// the GO loop examines. It says nothing about the work the database does to
|
|
// produce them: no index matches this query's five filters together
|
|
// (document_id, action, created_at, actor, user_id — and there is no index
|
|
// on `actor` at all), so the plan is the planner's
|
|
// choice and the rows it visits are not bounded by this constant. What is
|
|
// bounded is the QUERY COUNT — BUG-2770's retry loop issues at most
|
|
// debounceMergeAttempts candidate queries, each losing attempt adding one
|
|
// UPDATE and at most one primary-key probe, or, when the attempt MISSED, one
|
|
// locked re-read of the candidate in insertDebouncedOnMiss (BUG-2777).
|
|
//
|
|
// Deliberately not claiming more than that here. The earlier version of
|
|
// this comment asserted which index the planner would use and that the cost
|
|
// stays small; both were guesses about a planner, and two review rounds
|
|
// went into correcting them. If this write path ever shows up in a profile,
|
|
// measure the plan on the dialect that hurts before adding an index.
|
|
const maxDebounceCandidates = 10
|
|
|
|
func (s *Store) CreateActivity(a models.Activity) (string, error) {
|
|
return s.createActivityQ(s.db, a)
|
|
}
|
|
|
|
// createActivityQ is CreateActivity against an executor, so a caller that
|
|
// must commit the activity row together with the row that references it
|
|
// (comments.activity_id, BUG-2716) can run it inside its own transaction.
|
|
func (s *Store) createActivityQ(q execQueryer, a models.Activity) (string, error) {
|
|
a.ID = newID()
|
|
if a.Metadata == "" {
|
|
a.Metadata = "{}"
|
|
}
|
|
ts := now()
|
|
|
|
_, err := q.Exec(s.q(`
|
|
INSERT INTO activities (id, workspace_id, document_id, action, actor, source, metadata, user_id, ip_address, user_agent, created_at, via_app)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), a.ID, nilIfEmpty(a.WorkspaceID), nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), nilIfEmpty(a.IPAddress), nilIfEmpty(a.UserAgent), ts, nilIfEmpty(a.ViaApp))
|
|
if err != nil {
|
|
// No id alongside an error (BUG-2779). The id in hand names a row
|
|
// that was NOT written, and a caller who ignores the error — as one
|
|
// did — gets a plausible-looking handle to nothing. Returning "" makes
|
|
// the misuse impossible instead of merely wrong.
|
|
return "", err
|
|
}
|
|
return a.ID, nil
|
|
}
|
|
|
|
// A non-nil error is always paired with an EMPTY id (BUG-2779). Neither this
|
|
// function nor CreateActivity returns a usable-looking handle beside a
|
|
// failure: the id a failed call has in hand names a row it did not write, and
|
|
// a caller that ignores the error cannot then link anything to it.
|
|
//
|
|
// CreateActivityDebounced creates a new activity or updates an existing one if a
|
|
// matching activity (same document, same action, and same WRITER — same user
|
|
// account, same actor kind, and same agent name — through the same SOURCE)
|
|
// was recorded within the cooldown window. The source is part of the key
|
|
// because one person editing in the web UI and then from the CLI made two
|
|
// changes a History should show apart; merged, the row carried the second
|
|
// door's source and time, and the intermediate value vanished (PLAN-2348
|
|
// checkpoint 2, defect 1). Only "updated" actions are debounced; all other actions
|
|
// always create a new entry.
|
|
//
|
|
// When merging, the existing activity's timestamp is bumped to now and its
|
|
// metadata changes are accumulated (so a rapid sequence of field edits produces
|
|
// a single activity whose metadata lists all changed fields).
|
|
func (s *Store) CreateActivityDebounced(a models.Activity) (string, error) {
|
|
if a.Metadata == "" {
|
|
a.Metadata = "{}"
|
|
}
|
|
|
|
// Only debounce "updated" actions — everything else always creates a new row.
|
|
if a.Action != "updated" {
|
|
return s.CreateActivity(a)
|
|
}
|
|
|
|
cutoff := time.Now().UTC().Add(-ActivityDebounceCooldown).Format(time.RFC3339)
|
|
|
|
// Look for a recent activity of THIS WRITER's to coalesce with, newest
|
|
// first. If a comment links the row we choose (comments.activity_id), the
|
|
// merge below REFUSES it and a fresh row is written instead: the comment
|
|
// reads its agent name from the row's metadata, and a merge overlays the
|
|
// incoming `agent` and bumps created_at, so a later update by a different
|
|
// agent under the same credentials would otherwise silently re-attribute
|
|
// an earlier comment (TASK-2760, codex round 3). A linked row therefore
|
|
// ENDS a coalescing run; the next update starts a new one. That refusal
|
|
// lives in the UPDATE's own predicate and nowhere else, because a comment
|
|
// can link the row between this read and that write (codex round 6) — a
|
|
// check here would be a second guard that only looks load-bearing. That
|
|
// still holds with BUG-2770's compare-and-set arm on the same UPDATE:
|
|
// debounceRowUnchanged runs only AFTER a refusal, asks about the
|
|
// metadata rather than the link, and decides a disposition — it never
|
|
// gates the write, so the link check is still made in exactly one
|
|
// place. What this does NOT close is the opposite order, an update that completes
|
|
// before the link is VISIBLE to it — the comment row is written in its
|
|
// own transaction, so on Postgres a link committing while this UPDATE's
|
|
// snapshot is already open is missed exactly as an unwritten one is.
|
|
// Closing that needs both rows in one transaction, which the two
|
|
// "commented" sites now do (BUG-2716, CreateCommentWithActivity); the
|
|
// "updated" site keeps its two steps by ruling, since its activity
|
|
// records a write that already committed.
|
|
//
|
|
// THE WRITER, NOT THE ACCOUNT (BUG-2763). One account is shared by the
|
|
// human and by every agent authenticating as them, so user_id does not
|
|
// identify the writer: `actor` separates a human's write from an agent's,
|
|
// and the `agent` name inside metadata separates two agents from each
|
|
// other. Coalescing across either produces a row naming the wrong writer
|
|
// in both directions, because the UPDATE writes only metadata and
|
|
// created_at — the surviving row keeps the FIRST write's actor — while
|
|
// mergeActivityMeta overlays `agent` only when the incoming write carries
|
|
// one and leaves the stale name standing when it does not — which is the
|
|
// two directions. Both are visible:
|
|
// TimelineActivityCard reads the stamped name only when actor == "agent"
|
|
// and prints the person otherwise.
|
|
//
|
|
// actor is a SQL predicate; the agent name is matched in Go. Not for want
|
|
// of a portable accessor — s.dialect.JSONExtractText would render this
|
|
// column's `agent` key on both backends — but because
|
|
// models.AgentNameFromMetadata is the twin of the accessor the RENDERER
|
|
// uses, and the two disagree exactly where a stored value is odd: a
|
|
// non-string `agent` reads as absent in Go, while json_extract yields a
|
|
// native number or boolean that no text comparison matches, and malformed
|
|
// metadata is one skipped row in Go versus a failed query on SQLite.
|
|
// Matching on the value the timeline will actually display keeps the
|
|
// merge decision and the rendering in agreement.
|
|
incomingAgent := models.AgentNameFromMetadata(a.Metadata)
|
|
|
|
// The read and the write are separate statements, so the row can move
|
|
// under us between them — see debounceMergeAttempts. Each attempt
|
|
// re-reads the candidate, so the blob a losing attempt merges into is
|
|
// the winner's result rather than the stale one it first read.
|
|
for attempt := 0; attempt < debounceMergeAttempts; attempt++ {
|
|
existingID, existingMeta, ok := s.recentDebounceCandidate(a, cutoff, incomingAgent)
|
|
if !ok || existingID == "" {
|
|
if !ok {
|
|
// Query trouble: nothing to coalesce with that we can see.
|
|
return s.CreateActivity(a)
|
|
}
|
|
// Nothing recent of this writer's. Test seam (BUG-2777): the
|
|
// window in which a concurrent call that also found nothing
|
|
// inserts first. Nil in production.
|
|
if s.afterDebounceMiss != nil {
|
|
s.afterDebounceMiss()
|
|
}
|
|
id, raced, err := s.insertDebouncedOnMiss(a, cutoff, incomingAgent)
|
|
if err != nil || !raced {
|
|
return id, err
|
|
}
|
|
// Another call of this writer's started the run first. Go around
|
|
// and merge into it, like any other contended attempt.
|
|
continue
|
|
}
|
|
|
|
// Test seam: runs between the read and the write, which is exactly
|
|
// the window this loop exists to survive. Nil in production.
|
|
if s.afterDebounceRead != nil {
|
|
s.afterDebounceRead()
|
|
}
|
|
|
|
// Merge metadata: accumulate "changes" strings from both old and new.
|
|
merged := mergeActivityMeta(existingMeta, a.Metadata)
|
|
|
|
// Stamped per ATTEMPT, not once before the loop (codex round 3): the
|
|
// merge bumps created_at because the row now carries a change that
|
|
// just happened, and a retry that stamped the pre-loop instant would
|
|
// backdate a row whose newest change is younger than that — the
|
|
// window this row stays coalescable in, its place in the timeline's
|
|
// ordering, and the backfill's chronology all read created_at. The
|
|
// cutoff deliberately does NOT move: it bounds which rows were
|
|
// eligible when the call began, and re-deriving it mid-call would
|
|
// let a long retry chain reach back further than the caller asked.
|
|
ts := now()
|
|
|
|
mergedOK, err := s.mergeIntoUnlinkedActivity(existingID, existingMeta, merged, ts)
|
|
if err != nil {
|
|
// "" and not existingID (BUG-2779): that id names the row this
|
|
// call CHOSE, not one it wrote. Handing it back beside an error
|
|
// gave the item-update handler — which discarded the error — a
|
|
// real id for an OLDER activity, and a comment created in the
|
|
// same request linked to it. The comment then sits under an entry
|
|
// describing a different change, attributed by TASK-2760's
|
|
// agent-name rule to whoever wrote that entry.
|
|
return "", err
|
|
}
|
|
if mergedOK {
|
|
return existingID, nil
|
|
}
|
|
|
|
// The UPDATE matched nothing, and its predicate has two arms that
|
|
// can do that. Ask which, rather than picking the disposition that
|
|
// happens to be safe for both: they want OPPOSITE handling — a
|
|
// frozen row must not be retried (the answer will not change), and
|
|
// a contended row must not start a new run (retrying merges into
|
|
// the winner's blob and keeps the timeline entry whole).
|
|
// Test seam (BUG-2779): between the refusal and the probe that
|
|
// classifies it. Nil in production.
|
|
if s.afterDebounceRefusal != nil {
|
|
s.afterDebounceRefusal()
|
|
}
|
|
|
|
unchanged, err := s.debounceRowUnchanged(existingID, existingMeta)
|
|
if err != nil {
|
|
// Same contract as the merge failure above (BUG-2779). Reaching
|
|
// it needs the UPDATE to affect zero rows and THEN this probe to
|
|
// fail — two statements apart — which is what afterDebounceRefusal
|
|
// exists to schedule. An earlier version of this comment claimed
|
|
// no instrument could reach it; that was a fact about the seams I
|
|
// had written, not about the code, and review said so.
|
|
return "", err
|
|
}
|
|
if unchanged {
|
|
// The blob we read is still there, so the CAS arm held and the
|
|
// refusal was the comment link — before the read above, or
|
|
// since. Frozen: a fresh row starts the next run.
|
|
//
|
|
// "Frozen" is about THIS call, not forever: DeleteComment can
|
|
// unlink the row a moment later (codex round 8). The cost of
|
|
// having decided otherwise is one extra activity row, which is
|
|
// the same currency every other giving-up path here spends.
|
|
return s.CreateActivity(a)
|
|
}
|
|
// Someone else merged into this row (or deleted it) between our
|
|
// read and our write. Go around: re-read and merge into what they
|
|
// left.
|
|
}
|
|
|
|
// Contended past the bound. A new row costs one extra timeline entry
|
|
// and loses no change text, which is the direction this bug exists to
|
|
// prevent — unlike the alternative of overwriting a merge we know
|
|
// happened. See debounceMergeAttempts.
|
|
return s.CreateActivity(a)
|
|
}
|
|
|
|
// REFUSAL ARMS — read this before adding a predicate to
|
|
// mergeIntoUnlinkedActivity's UPDATE. That statement refuses on exactly two
|
|
// grounds today (the comment link, and the compare-and-set below), and
|
|
// debounceRowUnchanged distinguishes them by asking the CAS question alone:
|
|
// CAS still matches => it must have been the link. A THIRD arm added to the
|
|
// UPDATE without a matching question in the probe would be classified from
|
|
// metadata alone and silently read as "frozen".
|
|
//
|
|
// Left as an invariant rather than a framework because the drift is BOUNDED
|
|
// in a way worth stating: either misclassification (frozen read as
|
|
// contended, or the reverse) costs at most one wasted attempt or one extra
|
|
// activity row. Neither can lose change text, which is the thing this whole
|
|
// mechanism exists to protect. A composition layer over a two-arm predicate
|
|
// would cost more clarity than the failure it prevents.
|
|
//
|
|
// debounceCASPredicate is the compare-and-set arm of the debounce merge:
|
|
// "the row still carries the blob the caller merged from". It is ONE string
|
|
// used by both statements that ask it — the UPDATE that enforces it and the
|
|
// probe that decides, after a refusal, which arm refused (BUG-2770).
|
|
//
|
|
// Shared rather than written twice on purpose (codex round 6): if the two
|
|
// spellings could drift, the probe would answer a question the UPDATE is
|
|
// not asking, and the disposition it picks — retry, or start a fresh row —
|
|
// would silently be the wrong one. This makes them the same question by
|
|
// construction rather than by a comment asking the next editor to keep them
|
|
// in step.
|
|
const debounceCASPredicate = `metadata = ?`
|
|
|
|
// debounceMergeAttempts bounds how many times CreateActivityDebounced will
|
|
// re-read and retry a merge whose compare-and-set lost to a concurrent
|
|
// writer (BUG-2770).
|
|
//
|
|
// It is a liveness bound, not a correctness one. An attempt that loses the
|
|
// CAS means the row is no longer the one we merged from — because another
|
|
// writer merged into it, or because it was deleted — so the only changes at
|
|
// risk from giving up are the ones THIS call is carrying, and the fallback
|
|
// (a new activity row) still records them, at the cost of one extra
|
|
// timeline entry. Exhausting the bound therefore degrades presentation,
|
|
// never content.
|
|
//
|
|
// Three because the racing pair must already be the same account, the same
|
|
// actor kind AND the same agent name on the same item inside five minutes
|
|
// (BUG-2763's narrowing), so contention comes from one editor's autosave
|
|
// burst or one agent's concurrent PATCHes. A single retry clears the
|
|
// two-writer case that BUG-2770 filed; the third is headroom for a third
|
|
// racer arriving mid-retry. Raising it trades a longer write path for a
|
|
// case that has to be pathological to reach; it is not load-bearing for
|
|
// correctness in either direction.
|
|
const debounceMergeAttempts = 3
|
|
|
|
// recentDebounceCandidate reads the newest activity row of THIS WRITER's
|
|
// inside the cooldown window, with the metadata blob it carries right now.
|
|
// The blob is returned because it is the compare-and-set token the merge
|
|
// UPDATE uses, not merely the input to the merge.
|
|
//
|
|
// ok is false when the registry could not be read at all (query or scan
|
|
// trouble); the caller starts a new run rather than guessing. An empty id
|
|
// with ok true means the read succeeded and this writer has no recent row.
|
|
func (s *Store) recentDebounceCandidate(a models.Activity, cutoff, incomingAgent string) (id, metadata string, ok bool) {
|
|
return s.recentDebounceCandidateQ(s.db, a, cutoff, incomingAgent)
|
|
}
|
|
|
|
// rowsQueryer is the multi-row read both *sql.DB and *sql.Tx provide.
|
|
type rowsQueryer interface {
|
|
Query(query string, args ...any) (*sql.Rows, error)
|
|
}
|
|
|
|
// insertDebouncedOnMiss starts a new run for a writer whose unlocked
|
|
// candidate read found nothing (BUG-2777). Two calls of one writer that both
|
|
// miss would otherwise both insert, so the run the debounce exists to
|
|
// coalesce starts as two rows; measured, four concurrent calls did so in 29
|
|
// of 30 bursts on both backends (BUG-2777 checkpoint 1).
|
|
//
|
|
// The miss is re-checked and the insert made as ONE serialized decision per
|
|
// document: a transaction holding a lock keyed on the document, re-reading
|
|
// the candidate inside it, and inserting only if it is still absent. On
|
|
// Postgres the lock is an advisory transaction lock, and the re-read, a new
|
|
// READ COMMITTED statement issued after it is granted, sees the row the
|
|
// previous holder committed. On SQLite the DSN's _txlock=immediate makes
|
|
// BEGIN itself the lock.
|
|
//
|
|
// raced reports that a row appeared between the unlocked read and the lock:
|
|
// nothing was written, and the caller merges into that row through the
|
|
// ordinary compare-and-set path.
|
|
//
|
|
// LOCK ORDER: the debounce lock is a LEAF. It is taken first in its own
|
|
// transaction, and nothing else is locked while it is held: no advisory lock,
|
|
// no row lock beyond the INSERT's foreign-key check. It is also never taken
|
|
// inside a transaction that already holds one. Every advisory key shares
|
|
// hashtext's int32 space, so a nested inversion would deadlock even between
|
|
// unrelated keys. The single caller, internal/server/handlers_documents.go
|
|
// logActivityWithMetaReturningID, runs outside any transaction; none of the
|
|
// server's *sql.Tx prechecks (which run inside UpdateItem's seq-locked tx)
|
|
// reaches it (BUG-2777 checkpoint 4). A caller that must log from inside
|
|
// such a transaction takes the seq lock first and this one second, and
|
|
// nothing may take them the other way round.
|
|
func (s *Store) insertDebouncedOnMiss(a models.Activity, cutoff, incomingAgent string) (id string, raced bool, err error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("begin debounce insert: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
if _, err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext('pad:activity-debounce:' || $1))", a.DocumentID); err != nil {
|
|
return "", false, fmt.Errorf("acquire debounce lock: %w", err)
|
|
}
|
|
}
|
|
|
|
existingID, _, ok := s.recentDebounceCandidateQ(tx, a, cutoff, incomingAgent)
|
|
if !ok {
|
|
// Query trouble on the re-read: degrade exactly as the unlocked read
|
|
// does, to a fresh row. A Postgres transaction is aborted after a
|
|
// failed statement, so that row is written outside it.
|
|
_ = tx.Rollback()
|
|
id, err := s.CreateActivity(a)
|
|
return id, false, err
|
|
}
|
|
if existingID != "" {
|
|
return "", true, nil
|
|
}
|
|
// Test seam (BUG-2777): inside the lock, after the re-read missed and
|
|
// before the insert. Nil in production.
|
|
if s.afterDebounceLockedMiss != nil {
|
|
s.afterDebounceLockedMiss()
|
|
}
|
|
id, err = s.createActivityQ(tx, a)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return "", false, fmt.Errorf("commit debounce insert: %w", err)
|
|
}
|
|
return id, false, nil
|
|
}
|
|
|
|
func (s *Store) recentDebounceCandidateQ(q rowsQueryer, a models.Activity, cutoff, incomingAgent string) (id, metadata string, ok bool) {
|
|
rows, err := q.Query(s.q(`
|
|
SELECT id, metadata FROM activities
|
|
WHERE document_id = ? AND action = ? AND created_at >= ? AND actor = ? AND source = ?
|
|
AND ((user_id IS NOT NULL AND user_id = ?) OR (user_id IS NULL AND ? = ''))
|
|
AND COALESCE(via_app, '') = ?
|
|
ORDER BY created_at DESC LIMIT ?
|
|
`), a.DocumentID, a.Action, cutoff, a.Actor, a.Source, a.UserID, a.UserID, a.ViaApp, maxDebounceCandidates)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
|
|
var foundID, foundMeta string
|
|
for rows.Next() {
|
|
var rowID, meta string
|
|
if err := rows.Scan(&rowID, &meta); err != nil {
|
|
_ = rows.Close()
|
|
return "", "", false
|
|
}
|
|
if models.AgentNameFromMetadata(meta) == incomingAgent {
|
|
foundID, foundMeta = rowID, meta
|
|
break
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = rows.Close()
|
|
return "", "", false
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return "", "", false
|
|
}
|
|
return foundID, foundMeta, true
|
|
}
|
|
|
|
// debounceRowUnchanged reports whether the row still carries the exact
|
|
// metadata the caller read, and is the only thing that can tell the merge
|
|
// UPDATE's two refusal arms apart after the fact.
|
|
//
|
|
// It asks the CAS arm's own question, so a true answer means the CAS would
|
|
// still match and the refusal must have come from the comment-link arm. A
|
|
// false answer covers both "another writer merged into it" and "it is gone"
|
|
// — the caller treats them the same way (go around; a vanished row simply
|
|
// fails to be selected next time), so nothing is served by separating them
|
|
// here.
|
|
//
|
|
// It is a DISPOSITION HINT, not a guard. Nothing holds the row still
|
|
// between this probe and the next attempt, so its answer can be stale the
|
|
// moment it returns; the loop re-reads and re-runs the CAS rather than
|
|
// trusting it, and the worst a stale hint costs is one wasted attempt or
|
|
// one extra activity row (codex round 7).
|
|
func (s *Store) debounceRowUnchanged(id, metadata string) (bool, error) {
|
|
var found int
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT 1 FROM activities WHERE id = ? AND `+debounceCASPredicate+`
|
|
`), id, metadata).Scan(&found)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// mergeIntoUnlinkedActivity applies a debounce merge to one activity row in a
|
|
// single statement whose predicate refuses the row on two grounds: a comment
|
|
// links it, or its metadata is no longer the blob the caller merged from.
|
|
// It reports whether the row was written; false means one of those arms
|
|
// refused and the caller must not treat it as merged. See the comment in
|
|
// CreateActivityDebounced for why the comment-link check lives in the UPDATE,
|
|
// and debounceRowUnchanged for how a caller tells the two arms apart.
|
|
//
|
|
// expectedMetadata is the compare-and-set token (BUG-2770): the merge is a
|
|
// read-modify-write across two statements, and without it two writers who
|
|
// select the SAME row each merge their own change into the blob they read
|
|
// and the second UPDATE silently erases the first one's change entry. The
|
|
// comparison is exact-text on SQLite and jsonb equality on Postgres, where
|
|
// the column is JSONB — both answer the only question being asked, "is this
|
|
// still the value I merged from", because the string being compared is the
|
|
// one that column just handed us.
|
|
func (s *Store) mergeIntoUnlinkedActivity(id, expectedMetadata, metadata, ts string) (bool, error) {
|
|
res, err := s.db.Exec(s.q(`
|
|
UPDATE activities SET metadata = ?, created_at = ?
|
|
WHERE id = ?
|
|
AND `+debounceCASPredicate+`
|
|
AND NOT EXISTS (SELECT 1 FROM comments c WHERE c.activity_id = activities.id AND c.item_id = activities.document_id)
|
|
`), metadata, ts, id, expectedMetadata)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return n == 1, nil
|
|
}
|
|
|
|
// mergeActivityMeta combines two activity metadata JSON strings, accumulating
|
|
// the "changes" field. If both contain changes, they are joined with "; ".
|
|
// Other metadata fields (like "agent") are preserved from both sides.
|
|
func mergeActivityMeta(existing, incoming string) string {
|
|
var existMap, newMap map[string]interface{}
|
|
if err := json.Unmarshal([]byte(existing), &existMap); err != nil {
|
|
existMap = make(map[string]interface{})
|
|
}
|
|
if err := json.Unmarshal([]byte(incoming), &newMap); err != nil {
|
|
newMap = make(map[string]interface{})
|
|
}
|
|
// A JSON `null` blob unmarshals with NO error and leaves the map nil,
|
|
// and the overlay below writes into existMap — assignment to a nil map
|
|
// panics (codex round 3). The error branches above cannot catch this
|
|
// because there is no error: `null` is valid JSON, valid JSONB on
|
|
// Postgres, and storable in the TEXT column on SQLite. Ranging over a
|
|
// nil newMap is already safe; existMap is the one that must be real.
|
|
if existMap == nil {
|
|
existMap = make(map[string]interface{})
|
|
}
|
|
|
|
// Accumulate changes
|
|
existChanges, _ := existMap["changes"].(string)
|
|
newChanges, _ := newMap["changes"].(string)
|
|
|
|
// PLAN-3578: decide the seq chain from both sides BEFORE the overlay.
|
|
chainExist := map[string]interface{}{}
|
|
for _, k := range []string{ActivityMetaSeq, ActivityMetaPrevSeq} {
|
|
if v, ok := existMap[k]; ok {
|
|
chainExist[k] = v
|
|
}
|
|
}
|
|
|
|
// Start with existing map as base, overlay new fields
|
|
for k, v := range newMap {
|
|
if k == "changes" {
|
|
continue // handled separately
|
|
}
|
|
existMap[k] = v
|
|
}
|
|
if _, stamped := chainExist[ActivityMetaSeq]; stamped || newMap[ActivityMetaSeq] != nil {
|
|
broken := existMap[ActivitySeqChainBroken]
|
|
mergeSeqChain(chainExist, newMap)
|
|
if v, ok := chainExist[ActivitySeqChainBroken]; ok {
|
|
existMap[ActivitySeqChainBroken] = v
|
|
} else if broken != nil {
|
|
existMap[ActivitySeqChainBroken] = broken
|
|
}
|
|
if v, ok := chainExist[ActivityMetaPrevSeq]; ok && chainExist[ActivitySeqChainBroken] == nil {
|
|
existMap[ActivityMetaPrevSeq] = v
|
|
}
|
|
}
|
|
|
|
if newChanges != "" {
|
|
if existChanges != "" {
|
|
existMap["changes"] = collapseChanges(existChanges + "; " + newChanges)
|
|
} else {
|
|
existMap["changes"] = collapseChanges(newChanges)
|
|
}
|
|
}
|
|
// If neither had changes, don't add an empty one.
|
|
// If only existing had changes, it's already in existMap.
|
|
|
|
result, err := json.Marshal(existMap)
|
|
if err != nil {
|
|
return existing
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
// collapseChanges takes a "; "-delimited list of "field: old → new" entries
|
|
// and collapses runs of consecutive same-field entries into a single
|
|
// "field: first-old → last-new" entry. Net no-ops (first-old == last-new
|
|
// after collapse) are dropped entirely.
|
|
//
|
|
// Why: CreateActivityDebounced accumulates changes across PATCHes within
|
|
// the 5-minute cooldown. Before BUG-1466's web-side typing debounce,
|
|
// rapid typing in a structured text field produced 30+ chained
|
|
// per-keystroke entries on a single activity row (visible on BUG-1419's
|
|
// timeline). Even with the debounce in place, two edits to the same
|
|
// field in the same window still read more naturally as one transition.
|
|
//
|
|
// Parsing assumptions:
|
|
// - Entries are joined by "; " — diffFields uses the same separator so
|
|
// a single split-by-"; " produces a flat list regardless of whether
|
|
// the changes string came from one multi-field PATCH or several.
|
|
// - Each entry has the shape "<field>: <from> → <to>" with " → " (U+2192,
|
|
// padded) as the arrow. Unparseable entries are preserved verbatim
|
|
// (they never match a field name, so they never collapse).
|
|
// - Collapse is run-based, not global: `a:1→2; b:x→y; a:2→3` stays as-is
|
|
// (interleaved field edits keep their chronology). Easier to reason
|
|
// about and more accurate as a history.
|
|
func collapseChanges(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
const arrow = " → "
|
|
type entry struct {
|
|
field, from, to string
|
|
raw string // preserved verbatim for unparseable entries
|
|
// mergedCount tracks how many input segments were collapsed into
|
|
// this entry. mergedCount > 1 means at least one merge happened.
|
|
mergedCount int
|
|
// hadTransition is true iff any input entry in the run produced a
|
|
// display-string transition — i.e. either the initial entry had
|
|
// from != to, or a subsequent entry's `to` differed from the
|
|
// run's anchored `from`. Used together with hasLossySummary
|
|
// below to decide whether a from==to result is a true net no-op.
|
|
hadTransition bool
|
|
// hasLossySummary is true iff ANY entry in the run carried a
|
|
// `(N items)` / `(object)` / `(1 note)` display value — the
|
|
// lossy summary form that formatChangeValue emits for
|
|
// structured fields (handlers_documents.go::formatChangeValue).
|
|
//
|
|
// When the display value is lossy, a from==to result CAN'T be
|
|
// trusted as "no underlying change." Counter-example: editing
|
|
// implementation_notes from 1 note → 2 notes → 1 different
|
|
// note collapses to `(1 note) → (1 note)` with hadTransition=true,
|
|
// but the final note differs from the original — diffFields
|
|
// emitted both entries because reflect.DeepEqual saw real
|
|
// changes (see TestDiffFieldsSameCardinalityArrayChangeStillReported).
|
|
// We can't recover the raw values from the merged string, so
|
|
// we never drop a run that touched a lossy summary. Per Codex
|
|
// review round 3 [P2].
|
|
hasLossySummary bool
|
|
}
|
|
|
|
// isLossySummary detects formatChangeValue's display labels:
|
|
// `(N items)`, `(1 note)`, `(2 entries)`, `(object)`, etc. The
|
|
// shape — parenthesised, never produced for primitive values — is
|
|
// stable across formatChangeValue's known and fallback branches.
|
|
isLossySummary := func(v string) bool {
|
|
return len(v) >= 2 && strings.HasPrefix(v, "(") && strings.HasSuffix(v, ")")
|
|
}
|
|
parts := strings.Split(s, "; ")
|
|
entries := make([]entry, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
colonIdx := strings.Index(p, ":")
|
|
if colonIdx == -1 {
|
|
entries = append(entries, entry{raw: p})
|
|
continue
|
|
}
|
|
field := strings.TrimSpace(p[:colonIdx])
|
|
// NB: don't TrimSpace the value-part — diffFields formats it as
|
|
// " <from> → <to>" with a single leading space (the ": " format
|
|
// separator). Trimming that leading space breaks arrow detection
|
|
// when `from` is empty ("→ x" → no match for " → ").
|
|
valuePart := p[colonIdx+1:]
|
|
arrowIdx := strings.Index(valuePart, arrow)
|
|
if arrowIdx == -1 {
|
|
// Deletion-to-empty case: diffFields renders this as
|
|
// "<key>: <from> → " which loses its trailing space when
|
|
// the segment is TrimSpace'd. Catch it via suffix match.
|
|
if strings.HasSuffix(valuePart, " →") {
|
|
from := strings.TrimSpace(valuePart[:len(valuePart)-len(" →")])
|
|
entries = append(entries, entry{
|
|
field: field, from: from, to: "",
|
|
mergedCount: 1, hadTransition: from != "",
|
|
hasLossySummary: isLossySummary(from),
|
|
})
|
|
continue
|
|
}
|
|
entries = append(entries, entry{raw: p})
|
|
continue
|
|
}
|
|
from := strings.TrimSpace(valuePart[:arrowIdx])
|
|
to := strings.TrimSpace(valuePart[arrowIdx+len(arrow):])
|
|
entries = append(entries, entry{
|
|
field: field, from: from, to: to,
|
|
mergedCount: 1, hadTransition: from != to,
|
|
hasLossySummary: isLossySummary(from) || isLossySummary(to),
|
|
})
|
|
}
|
|
|
|
// Walk consecutively, extending runs of the same field.
|
|
collapsed := make([]entry, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.raw != "" {
|
|
collapsed = append(collapsed, e)
|
|
continue
|
|
}
|
|
if len(collapsed) > 0 {
|
|
prev := &collapsed[len(collapsed)-1]
|
|
if prev.raw == "" && prev.field == e.field {
|
|
if e.hadTransition || e.to != prev.from {
|
|
prev.hadTransition = true
|
|
}
|
|
// hasLossySummary is sticky across the run: once any
|
|
// entry brings a lossy display value into the chain,
|
|
// the merged result can never be safely treated as a
|
|
// raw-value no-op.
|
|
if e.hasLossySummary {
|
|
prev.hasLossySummary = true
|
|
}
|
|
prev.to = e.to
|
|
prev.mergedCount += e.mergedCount
|
|
continue
|
|
}
|
|
}
|
|
collapsed = append(collapsed, e)
|
|
}
|
|
|
|
// Drop only true net no-ops, defined as: a collapsed run
|
|
// (mergedCount > 1) whose from==to, whose intermediate `to` values
|
|
// transitioned away from the anchor at some point (hadTransition),
|
|
// AND whose display values are NOT lossy summaries (!hasLossySummary).
|
|
//
|
|
// Why each clause:
|
|
// - mergedCount > 1: a single entry is preserved because diffFields
|
|
// wouldn't emit it unless reflect.DeepEqual detected a real
|
|
// change. Only collapsed runs can synthesise a fake from==to.
|
|
// - hadTransition: distinguishes a real cancellation from a
|
|
// repeated same-display run (Codex round 2 [P2]).
|
|
// - !hasLossySummary: a `(1 note) → (2 notes) → (1 note)` run
|
|
// visibly transitions and visibly returns, but the underlying
|
|
// notes can be entirely different objects — the display labels
|
|
// are lossy. Never drop those (Codex round 3 [P2]).
|
|
kept := collapsed[:0]
|
|
for _, e := range collapsed {
|
|
if e.raw == "" && e.mergedCount > 1 && e.from == e.to && e.hadTransition && !e.hasLossySummary {
|
|
continue
|
|
}
|
|
kept = append(kept, e)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for i, e := range kept {
|
|
if i > 0 {
|
|
sb.WriteString("; ")
|
|
}
|
|
if e.raw != "" {
|
|
sb.WriteString(e.raw)
|
|
continue
|
|
}
|
|
// Match diffFields's format: `"<field>: → <to>"` when from is
|
|
// empty (one space between ":" and "→"), `"<field>: <from> → <to>"`
|
|
// otherwise. arrow already carries its own leading space.
|
|
sb.WriteString(e.field)
|
|
if e.from == "" {
|
|
sb.WriteString(":")
|
|
} else {
|
|
sb.WriteString(": ")
|
|
sb.WriteString(e.from)
|
|
}
|
|
sb.WriteString(arrow)
|
|
sb.WriteString(e.to)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// memberActivityCols is the column list for every activity read a WORKSPACE
|
|
// reader can reach: the item timeline, the item and document activity lists and
|
|
// the workspace feed (and through that the MCP and CLI activity doors). Those
|
|
// readers include viewers and guests holding a grant, so the member's
|
|
// ip_address and user_agent are NOT selected: the two trailing positions are
|
|
// empty literals, which keeps scanActivitiesWithUser shared. Only the
|
|
// instance-admin audit reads (ListAuditLog, ListUserActivity) select the real
|
|
// columns (BUG-3314). activities_member_cols_test.go fails if any of the three
|
|
// member reads returns either value again.
|
|
const memberActivityCols = `a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), '', ''`
|
|
|
|
// ListUserActivity returns activities originated by the given user, in
|
|
// reverse-chronological order. Powers the Activity tab of the admin user
|
|
// modal (T1554 consumer). Filters to activities where a.user_id = userID —
|
|
// i.e. events this user authored (item writes, comments, logins, etc.).
|
|
//
|
|
// Admin actions targeting this user (e.g. role_changed where this user is
|
|
// the target rather than the actor) live in metadata.target_user_id and
|
|
// are NOT included here; that's a separate "received" sub-feed that would
|
|
// need a JSON predicate. Filed as follow-up when needed.
|
|
//
|
|
// Pagination is by the (created_at, id) keyset cursor in params.Before /
|
|
// params.BeforeID, like its siblings (BUG-2781); params.Offset is still
|
|
// honoured for callers that have not moved. PLAN-1542 / TASK-1546.
|
|
func (s *Store) ListUserActivity(userID string, params models.ActivityListParams) ([]models.Activity, error) {
|
|
query := `
|
|
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.user_id = ?
|
|
`
|
|
args := []interface{}{userID}
|
|
|
|
if params.Action != "" {
|
|
query += " AND a.action = ?"
|
|
args = append(args, params.Action)
|
|
}
|
|
|
|
query += activityKeysetClause(params.Before, params.BeforeID, &args)
|
|
query += " ORDER BY a.created_at DESC, a.id DESC"
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
// Cap at 100 (rather than 50) so the handler's limit+1 "has more?" probe
|
|
// at the public page-size ceiling of 50 still works without the store
|
|
// silently truncating to 50 and tricking next_offset into reporting
|
|
// null. The handler is the source of truth for the per-page maximum;
|
|
// this is an inner-safety cap only (Codex review on PR #601).
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list user activity: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
func (s *Store) ListWorkspaceActivity(workspaceID string, params models.ActivityListParams) ([]models.Activity, error) {
|
|
query := `
|
|
SELECT ` + memberActivityCols + `
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.workspace_id = ?
|
|
`
|
|
args := []interface{}{workspaceID}
|
|
|
|
if params.Action != "" {
|
|
query += " AND a.action = ?"
|
|
args = append(args, params.Action)
|
|
}
|
|
if params.Actor != "" {
|
|
query += " AND a.actor = ?"
|
|
args = append(args, params.Actor)
|
|
}
|
|
if params.Source != "" {
|
|
query += " AND a.source = ?"
|
|
args = append(args, params.Source)
|
|
}
|
|
if !params.Since.IsZero() {
|
|
// created_at is stored as RFC3339 text — compare against a matching
|
|
// string, mirroring the days-cutoff path in this file.
|
|
query += " AND a.created_at >= ?"
|
|
args = append(args, params.Since.UTC().Format(time.RFC3339))
|
|
}
|
|
if params.CollectionID != "" {
|
|
// The item's CURRENT collection, which is also the one the feed's
|
|
// enrichment shows the row under (TASK-2219).
|
|
query += " AND a.document_id IN (SELECT id FROM items WHERE collection_id = ?)"
|
|
args = append(args, params.CollectionID)
|
|
}
|
|
if params.Visibility != nil {
|
|
query += activityVisibilityClause(params.Visibility, &args)
|
|
}
|
|
if params.ExcludeUnresolvedItems {
|
|
query += " AND (COALESCE(a.document_id, '') = '' OR EXISTS (SELECT 1 FROM items ui WHERE ui.id = a.document_id))"
|
|
}
|
|
|
|
query += activityKeysetClause(params.Before, params.BeforeID, &args)
|
|
query += " ORDER BY a.created_at DESC, a.id DESC"
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
// activityVisibilityClause is ActivityVisibility as SQL (BUG-3687). It is the
|
|
// feed handler's former post-LIMIT filter, keyed on the item's collection ID
|
|
// where that filter matched the collection's slug: a slug can be re-taken by
|
|
// another collection, an ID cannot (BUG-2631). The item is read
|
|
// include-deleted, as the feed's enrichment reads it, so an archived item's
|
|
// history stays behind its collection's visibility instead of passing as
|
|
// workspace-level.
|
|
func activityVisibilityClause(v *models.ActivityVisibility, args *[]interface{}) string {
|
|
var terms []string
|
|
if !v.ExcludeWorkspaceLevel {
|
|
terms = append(terms,
|
|
"COALESCE(a.document_id, '') = ''",
|
|
"NOT EXISTS (SELECT 1 FROM items vi WHERE vi.id = a.document_id)")
|
|
}
|
|
if len(v.CollectionIDs) > 0 {
|
|
term := "EXISTS (SELECT 1 FROM items vi WHERE vi.id = a.document_id" +
|
|
" AND vi.collection_id IN (" + placeholders(len(v.CollectionIDs)) + ")"
|
|
for _, id := range v.CollectionIDs {
|
|
*args = append(*args, id)
|
|
}
|
|
if len(v.GrantedItemIDs) > 0 {
|
|
term += " AND (vi.id IN (" + placeholders(len(v.GrantedItemIDs)) + ")"
|
|
for _, id := range v.GrantedItemIDs {
|
|
*args = append(*args, id)
|
|
}
|
|
if len(v.FullCollectionIDs) > 0 {
|
|
term += " OR vi.collection_id IN (" + placeholders(len(v.FullCollectionIDs)) + ")"
|
|
for _, id := range v.FullCollectionIDs {
|
|
*args = append(*args, id)
|
|
}
|
|
}
|
|
term += ")"
|
|
}
|
|
terms = append(terms, term+")")
|
|
}
|
|
if len(terms) == 0 {
|
|
return " AND 1 = 0"
|
|
}
|
|
return " AND (" + strings.Join(terms, " OR ") + ")"
|
|
}
|
|
|
|
func (s *Store) ListDocumentActivity(documentID string, params models.ActivityListParams) ([]models.Activity, error) {
|
|
query := `
|
|
SELECT ` + memberActivityCols + `
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.document_id = ?
|
|
`
|
|
args := []interface{}{documentID}
|
|
|
|
if params.Action != "" {
|
|
query += " AND a.action = ?"
|
|
args = append(args, params.Action)
|
|
}
|
|
if params.Actor != "" {
|
|
query += " AND a.actor = ?"
|
|
args = append(args, params.Actor)
|
|
}
|
|
|
|
query += activityKeysetClause(params.Before, params.BeforeID, &args)
|
|
query += " ORDER BY a.created_at DESC, a.id DESC"
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
// ListDocumentActivityBeforeTime returns activities for a document created before the given time,
|
|
// ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination.
|
|
//
|
|
// Activities a comment links to (comments.activity_id) are EXCLUDED here, at
|
|
// query time — except an "updated" one that records a change. A "commented" row IS its comment, so
|
|
// the timeline shows it through the comment's card and never as its own
|
|
// entry. An "updated" row a comment links to (an update sent with --comment)
|
|
// records a field change the comment card does not render, and since comments
|
|
// moved to the Details tab (IDEA-2843) excluding it hid that change from every
|
|
// view (PLAN-2348 checkpoint 2, defect 5). For the rows that are excluded, the
|
|
// only correct place to drop them is the query:
|
|
// comments and activities are read through separately bounded windows over
|
|
// one cursor, so an activity can land inside its window while the comment
|
|
// that links it falls outside the comment window, and a handler that only
|
|
// suppresses activities whose comment it happens to hold then emits a
|
|
// standalone "commented" card for it (TASK-2760, codex round 2). Excluding at
|
|
// the source makes the suppression exact regardless of either window, and
|
|
// frees the window's slots for rows that will actually render. The lookup is
|
|
// a point probe on idx_comments_activity, present on both dialects.
|
|
//
|
|
// When beforeID is empty (first page / no cursor), the secondary id tie-breaker
|
|
// is omitted. See ListCommentsBeforeTime for the rationale (BUG-1086).
|
|
func (s *Store) ListDocumentActivityBeforeTime(documentID string, before time.Time, beforeID string, limit int) ([]models.Activity, error) {
|
|
ts := before.Format(time.RFC3339)
|
|
const selectCols = memberActivityCols
|
|
// The spared "updated" rows are the ones whose metadata carries a
|
|
// "changes" key: a linked update with nothing but the agent's name (a
|
|
// --comment sent alone) has nothing for its own card to say. The LIKE is
|
|
// a superset test (buildTimeline decides exactly), and it reads the text
|
|
// form because metadata is TEXT on SQLite and JSONB on Postgres, and
|
|
// json_extract refuses a row whose TEXT does not parse.
|
|
metaText := "a.metadata"
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
metaText = "a.metadata::text"
|
|
}
|
|
notCommentLinked := `AND ((a.action = 'updated' AND ` + metaText + ` LIKE '%"changes"%') OR NOT EXISTS (SELECT 1 FROM comments c WHERE c.activity_id = a.id AND c.item_id = a.document_id))`
|
|
const orderLimit = `ORDER BY a.created_at DESC, a.id DESC LIMIT ?`
|
|
|
|
var rows *sql.Rows
|
|
var err error
|
|
if beforeID == "" {
|
|
rows, err = s.db.Query(s.q(`
|
|
SELECT `+selectCols+`
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.document_id = ? AND a.created_at < ?
|
|
`+notCommentLinked+`
|
|
`+orderLimit), documentID, ts, limit)
|
|
} else {
|
|
rows, err = s.db.Query(s.q(`
|
|
SELECT `+selectCols+`
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.document_id = ? AND (a.created_at < ? OR (a.created_at = ? AND a.id < ?))
|
|
`+notCommentLinked+`
|
|
`+orderLimit), documentID, ts, ts, beforeID, limit)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
// activityKeysetClause is the keyset predicate the paginated activity feeds
|
|
// share (BUG-2781): rows strictly after the cursor in (created_at, id)
|
|
// DESCENDING order, which is the ORDER BY every feed uses. It appends its
|
|
// arguments to args and returns "" when there is no cursor.
|
|
//
|
|
// WHY A KEYSET RATHER THAN OFFSET: activity rows move. A debounce merge
|
|
// restamps created_at to now (mergeIntoUnlinkedActivity), so between two page
|
|
// requests an older row can jump to the head and every row behind it shifts
|
|
// down one position; the next OFFSET page then repeats a row the client
|
|
// already has. A cursor names a position in the ORDER, not a count, so rows
|
|
// moving ahead of it do not shift what comes after it.
|
|
//
|
|
// WHAT IT DOES NOT FIX, stated so nobody claims it: the moved row itself is
|
|
// now ahead of the cursor and no later page returns it. A client sees it only
|
|
// by re-reading the head of the feed. Pinned by
|
|
// TestOffsetPagingRepeatsARowWhenADebounceMergeMovesAnOlderOne and its keyset
|
|
// twin.
|
|
//
|
|
// An empty beforeID with a non-zero before is a timestamp-only cursor: rows
|
|
// strictly older than that second. It can skip rows that share the cursor's
|
|
// second, which is why the HTTP doors require both.
|
|
func activityKeysetClause(before time.Time, beforeID string, args *[]interface{}) string {
|
|
if before.IsZero() {
|
|
return ""
|
|
}
|
|
ts := before.UTC().Format(time.RFC3339)
|
|
if beforeID == "" {
|
|
*args = append(*args, ts)
|
|
return " AND a.created_at < ?"
|
|
}
|
|
*args = append(*args, ts, ts, beforeID)
|
|
return " AND (a.created_at < ? OR (a.created_at = ? AND a.id < ?))"
|
|
}
|
|
|
|
func scanActivitiesWithUser(rows interface {
|
|
Next() bool
|
|
Scan(dest ...interface{}) error
|
|
Err() error
|
|
}) ([]models.Activity, error) {
|
|
var activities []models.Activity
|
|
for rows.Next() {
|
|
var a models.Activity
|
|
var createdAt string
|
|
if err := rows.Scan(&a.ID, &a.WorkspaceID, &a.DocumentID, &a.Action, &a.Actor, &a.Source, &a.Metadata, &a.UserID, &createdAt, &a.ActorName, &a.IPAddress, &a.UserAgent); err != nil {
|
|
return nil, err
|
|
}
|
|
a.CreatedAt = parseTime(createdAt)
|
|
activities = append(activities, a)
|
|
}
|
|
return activities, rows.Err()
|
|
}
|
|
|
|
func nilIfEmpty(s string) interface{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ListAuditLog returns activities matching the given audit log filters.
|
|
// Supports filtering by action, actor, workspace, and date range.
|
|
func (s *Store) ListAuditLog(params models.AuditLogParams) ([]models.Activity, error) {
|
|
// Build the full query with ? placeholders first, then rebind once at
|
|
// the end so PostgreSQL $1/$2/... numbering is correct across all filters.
|
|
query := `
|
|
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE 1=1
|
|
`
|
|
args := []interface{}{}
|
|
|
|
if params.WorkspaceID != "" {
|
|
query += ` AND a.workspace_id = ?`
|
|
args = append(args, params.WorkspaceID)
|
|
}
|
|
if params.Action != "" {
|
|
query += ` AND a.action = ?`
|
|
args = append(args, params.Action)
|
|
}
|
|
if params.Actor != "" {
|
|
query += ` AND a.user_id = ?`
|
|
args = append(args, params.Actor)
|
|
}
|
|
if params.Days > 0 {
|
|
cutoff := time.Now().UTC().AddDate(0, 0, -params.Days).Format(time.RFC3339)
|
|
query += ` AND a.created_at >= ?`
|
|
args = append(args, cutoff)
|
|
}
|
|
|
|
query += activityKeysetClause(params.Before, params.BeforeID, &args)
|
|
query += ` ORDER BY a.created_at DESC, a.id DESC`
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
query += fmt.Sprintf(` LIMIT %d`, limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(` OFFSET %d`, params.Offset)
|
|
}
|
|
|
|
// Rebind all ? placeholders in one pass so $1, $2, ... are sequential.
|
|
query = s.q(query)
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|