Skip to content

Commit 93ffdef

Browse files
committed
fix(data): scope published identity by session
Filtered mirrors must follow a session’s current project without rewriting its immutable source identity. Otherwise a project move can publish the session without its snapshot, retain evidence after the session leaves scope, or turn an uninspected cwd placeholder into authoritative project evidence. Rebuild filtered identity from destination state and reconcile only changed session IDs between full publications. This keeps watcher work bounded while avoiding transmission of excluded-project tombstones.
1 parent 6175d73 commit 93ffdef

13 files changed

Lines changed: 510 additions & 279 deletions

internal/db/project_identity.go

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ func (db *DB) LoadProjectIdentityPublicationDelta(
9595
}
9696

9797
where, args := projectIdentityPublicationChangeWhere(
98-
"c", afterRevision, throughRevision, projects, excludeProjects,
98+
"c", "c.project", afterRevision, throughRevision,
99+
projects, excludeProjects,
99100
)
100101
rows, err := db.getReader().QueryContext(ctx, `
101102
SELECT o.source_archive_id, o.source_archive_salt,
@@ -168,6 +169,10 @@ func (db *DB) LoadProjectIdentityPublicationDelta(
168169
return delta, fmt.Errorf("closing project identity observation tombstones: %w", err)
169170
}
170171

172+
snapshotWhere, snapshotArgs := projectIdentityPublicationChangeWhere(
173+
"c", "owner.project", afterRevision, throughRevision,
174+
projects, excludeProjects,
175+
)
171176
rows, err = db.getReader().QueryContext(ctx, `
172177
SELECT s.session_id, s.project, s.machine, s.root_path, s.git_remote,
173178
s.git_remote_name, s.repository_path, s.worktree_name,
@@ -177,8 +182,11 @@ func (db *DB) LoadProjectIdentityPublicationDelta(
177182
FROM session_project_identity_snapshot_changes c
178183
JOIN session_project_identity_snapshots s
179184
ON s.session_id = c.session_id AND s.project = c.project
180-
`+where+` AND c.deleted = 0
181-
ORDER BY c.session_id, c.project`, args...)
185+
JOIN sessions owner
186+
ON owner.id = s.session_id AND owner.deleted_at IS NULL
187+
`+snapshotWhere+` AND c.deleted = 0
188+
AND (TRIM(s.key_source) != '' OR TRIM(s.worktree_root_path) != '')
189+
ORDER BY c.session_id, c.project`, snapshotArgs...)
182190
if err != nil {
183191
return delta, fmt.Errorf("listing changed session project identity snapshots: %w", err)
184192
}
@@ -213,11 +221,15 @@ func (db *DB) LoadProjectIdentityPublicationDelta(
213221
return delta, fmt.Errorf("closing changed session project identity snapshots: %w", err)
214222
}
215223

224+
tombstoneWhere, tombstoneArgs := projectIdentityPublicationChangeWhere(
225+
"c", "c.project", afterRevision, throughRevision,
226+
projects, excludeProjects,
227+
)
216228
rows, err = db.getReader().QueryContext(ctx, `
217229
SELECT c.session_id, c.project
218230
FROM session_project_identity_snapshot_changes c
219-
`+where+` AND c.deleted = 1
220-
ORDER BY c.session_id, c.project`, args...)
231+
`+tombstoneWhere+` AND c.deleted = 1
232+
ORDER BY c.session_id, c.project`, tombstoneArgs...)
221233
if err != nil {
222234
return delta, fmt.Errorf("listing session project identity snapshot tombstones: %w", err)
223235
}
@@ -236,11 +248,13 @@ func (db *DB) LoadProjectIdentityPublicationDelta(
236248
}
237249

238250
func projectIdentityPublicationChangeWhere(
239-
alias string,
251+
revisionAlias string,
252+
projectExpression string,
240253
afterRevision, throughRevision int64,
241254
projects, excludeProjects []string,
242255
) (string, []any) {
243-
where := "WHERE " + alias + ".revision > ? AND " + alias + ".revision <= ?"
256+
where := "WHERE " + revisionAlias + ".revision > ? AND " +
257+
revisionAlias + ".revision <= ?"
244258
args := []any{afterRevision, throughRevision}
245259
appendProjects := func(values []string, negate bool) {
246260
if len(values) == 0 {
@@ -255,7 +269,7 @@ func projectIdentityPublicationChangeWhere(
255269
if negate {
256270
op = " NOT IN "
257271
}
258-
where += " AND " + alias + ".project" + op +
272+
where += " AND " + projectExpression + op +
259273
"(" + strings.Join(placeholders, ",") + ")"
260274
}
261275
appendProjects(projects, false)
@@ -1735,6 +1749,123 @@ func (db *DB) ListSessionProjectIdentitySnapshots(
17351749
return out, nil
17361750
}
17371751

1752+
// ListPublishableSessionProjectIdentitySnapshots returns authoritative source
1753+
// snapshots owned by sessions in the current project scope. sessionIDs limits
1754+
// the result when non-nil; an empty non-nil slice returns no rows. Placeholder
1755+
// snapshots created before a session is inspected are deliberately excluded.
1756+
func (db *DB) ListPublishableSessionProjectIdentitySnapshots(
1757+
ctx context.Context,
1758+
sessionIDs, projects, excludeProjects []string,
1759+
) ([]export.ProjectIdentityObservation, error) {
1760+
if ctx == nil {
1761+
ctx = context.Background()
1762+
}
1763+
if sessionIDs != nil && len(sessionIDs) == 0 {
1764+
return nil, nil
1765+
}
1766+
1767+
query := func(ids []string) ([]export.ProjectIdentityObservation, error) {
1768+
predicates := []string{
1769+
"owner.deleted_at IS NULL",
1770+
"(TRIM(snap.key_source) != '' OR TRIM(snap.worktree_root_path) != '')",
1771+
}
1772+
var args []any
1773+
appendSet := func(expression string, values []string, negate bool) {
1774+
if len(values) == 0 {
1775+
return
1776+
}
1777+
placeholders := make([]string, len(values))
1778+
for i, value := range values {
1779+
placeholders[i] = "?"
1780+
args = append(args, value)
1781+
}
1782+
op := " IN "
1783+
if negate {
1784+
op = " NOT IN "
1785+
}
1786+
predicates = append(
1787+
predicates,
1788+
expression+op+"("+strings.Join(placeholders, ",")+")",
1789+
)
1790+
}
1791+
appendSet("owner.project", projects, false)
1792+
appendSet("owner.project", excludeProjects, true)
1793+
appendSet("snap.session_id", ids, false)
1794+
1795+
rows, err := db.getReader().QueryContext(ctx, `
1796+
SELECT snap.session_id, snap.project, snap.machine, snap.root_path,
1797+
snap.git_remote, snap.git_remote_name, snap.repository_path,
1798+
snap.worktree_name, snap.worktree_root_path,
1799+
snap.worktree_relationship, snap.checkout_state,
1800+
snap.git_branch, snap.remote_resolution,
1801+
snap.remote_candidate_count, snap.observed_at,
1802+
snap.normalized_remote, snap.key_source, snap.key
1803+
FROM session_project_identity_snapshots snap
1804+
JOIN sessions owner ON owner.id = snap.session_id
1805+
WHERE `+strings.Join(predicates, " AND ")+`
1806+
ORDER BY snap.session_id`, args...)
1807+
if err != nil {
1808+
return nil, fmt.Errorf(
1809+
"listing publishable session project identity snapshots: %w",
1810+
err,
1811+
)
1812+
}
1813+
defer rows.Close()
1814+
1815+
var out []export.ProjectIdentityObservation
1816+
for rows.Next() {
1817+
var obs export.ProjectIdentityObservation
1818+
var observedAt string
1819+
if err := rows.Scan(
1820+
&obs.SessionID, &obs.Project, &obs.Machine, &obs.RootPath,
1821+
&obs.GitRemote, &obs.GitRemoteName, &obs.RepositoryPath,
1822+
&obs.WorktreeName, &obs.WorktreeRootPath,
1823+
&obs.WorktreeRelationship, &obs.CheckoutState,
1824+
&obs.GitBranch, &obs.RemoteResolution,
1825+
&obs.RemoteCandidateCount, &observedAt,
1826+
&obs.NormalizedRemote, &obs.KeySource, &obs.Key,
1827+
); err != nil {
1828+
return nil, fmt.Errorf(
1829+
"scanning publishable session project identity snapshot: %w",
1830+
err,
1831+
)
1832+
}
1833+
obs.ObservedAt, err = time.Parse(time.RFC3339Nano, observedAt)
1834+
if err != nil {
1835+
return nil, fmt.Errorf(
1836+
"parsing publishable session identity timestamp: %w", err,
1837+
)
1838+
}
1839+
out = append(out, obs)
1840+
}
1841+
if err := rows.Err(); err != nil {
1842+
return nil, fmt.Errorf(
1843+
"iterating publishable session project identity snapshots: %w",
1844+
err,
1845+
)
1846+
}
1847+
return out, nil
1848+
}
1849+
1850+
if sessionIDs == nil {
1851+
return query(nil)
1852+
}
1853+
chunkSize := maxSQLVars - len(projects) - len(excludeProjects)
1854+
if chunkSize < 1 {
1855+
chunkSize = 1
1856+
}
1857+
var out []export.ProjectIdentityObservation
1858+
err := queryChunkedSize(sessionIDs, chunkSize, func(ids []string) error {
1859+
rows, err := query(ids)
1860+
if err != nil {
1861+
return err
1862+
}
1863+
out = append(out, rows...)
1864+
return nil
1865+
})
1866+
return out, err
1867+
}
1868+
17381869
func (db *DB) BuildProjectIdentityMap(
17391870
ctx context.Context,
17401871
labels []string,

internal/duckdb/project_identity_upsert.go

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -56,38 +56,46 @@ func deleteProjectIdentityDelta(
5656
return nil
5757
}
5858

59-
func deleteProjectIdentityScope(
59+
func deleteProjectIdentityArchive(
6060
exec duckProjectIdentityExec,
6161
archiveID string,
62-
projects, excludeProjects []string,
6362
) error {
64-
args := []any{archiveID}
65-
predicates := []string{"source_archive_id = ?"}
66-
appendSet := func(column string, values []string, negate bool) {
67-
if len(values) == 0 {
68-
return
69-
}
70-
placeholders := make([]string, len(values))
71-
for i, value := range values {
72-
placeholders[i] = "?"
73-
args = append(args, value)
74-
}
75-
op := "IN"
76-
if negate {
77-
op = "NOT IN"
78-
}
79-
predicates = append(predicates,
80-
column+" "+op+" ("+strings.Join(placeholders, ",")+")")
81-
}
82-
appendSet("project", projects, false)
83-
appendSet("project", excludeProjects, true)
84-
where := strings.Join(predicates, " AND ")
8563
for _, table := range []string{
8664
"source_project_identity_observations",
8765
"source_session_project_identity_snapshots",
8866
} {
89-
if err := exec("DELETE FROM "+table+" WHERE "+where, args...); err != nil {
90-
return fmt.Errorf("clearing duckdb %s publication scope: %w", table, err)
67+
if err := exec(
68+
"DELETE FROM "+table+" WHERE source_archive_id = ?",
69+
archiveID,
70+
); err != nil {
71+
return fmt.Errorf("clearing duckdb %s archive: %w", table, err)
72+
}
73+
}
74+
return nil
75+
}
76+
77+
func deleteSessionProjectIdentitySnapshotsBySessionID(
78+
exec duckProjectIdentityExec,
79+
archiveID string,
80+
sessionIDs []string,
81+
) error {
82+
for start := 0; start < len(sessionIDs); start += projectIdentityDeleteBatchSize {
83+
end := min(start+projectIdentityDeleteBatchSize, len(sessionIDs))
84+
args := []any{archiveID}
85+
placeholders := make([]string, 0, end-start)
86+
for _, sessionID := range sessionIDs[start:end] {
87+
args = append(args, sessionID)
88+
placeholders = append(placeholders, "?")
89+
}
90+
if err := exec(`
91+
DELETE FROM source_session_project_identity_snapshots
92+
WHERE source_archive_id = ?
93+
AND source_session_id IN (`+
94+
strings.Join(placeholders, ", ")+`)`, args...); err != nil {
95+
return fmt.Errorf(
96+
"deleting duckdb session identity snapshots by session id: %w",
97+
err,
98+
)
9199
}
92100
}
93101
return nil

internal/duckdb/project_inventory_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ func pushDataReadMirror(t *testing.T, ctx context.Context, syncer *Sync) {
465465
require.NoError(t, createSchema(ctx, syncer.DB()), "createSchema")
466466
_, err := syncer.pushEverything(ctx, nil)
467467
require.NoError(t, err, "pushEverything")
468-
_, err = syncer.syncProjectIdentityObservations(ctx, 0, true)
468+
_, err = syncer.syncProjectIdentityObservations(ctx, 0, true, nil)
469469
require.NoError(t, err, "syncProjectIdentityObservations")
470470
_, err = syncer.syncWorktreeMappings(ctx, 0, true)
471471
require.NoError(t, err, "syncWorktreeMappings")

0 commit comments

Comments
 (0)