From 7d3a0f527e46b286c161b2cabc5bf4da0e8f5ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sun, 17 May 2026 04:24:05 +0100 Subject: [PATCH 1/4] fix: schema.sql drift + manage_feeds feed_key narrowing + submodule bump Three follow-ups dropped from PR #786 during the squash-merge: - db/schema.sql: insert the COLUMN COMMENT on personal_access_tokens.worker_id and move idx_personal_access_tokens_worker_id into its alphabetical spot so the file matches `dbmate up` output. The migrations CI job has been failing on this drift since 20260517030000_pat_worker_id_binding was added. - manage_feeds.list_feeds: narrow the event-counts scan by feed_key as well as connection_id (ANY-array on both axes); the LEFT JOIN at the end drops any over-count from the cross product. Cuts the 50-feed list on a busy connection from ~1.5s to ~670ms in prod. - Bump packages/web to ca12cd2 (owletto/main after PR #141), so the parent points at a submodule SHA reachable from owletto/main rather than at the now-orphaned pre-squash 2d2f5bf. --- db/schema.sql | 18 ++++++++++++------ .../server/src/tools/admin/manage_feeds.ts | 19 ++++++++++++------- packages/web | 2 +- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index 2465dc3d0..34fca3864 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1444,6 +1444,12 @@ CREATE TABLE public.personal_access_tokens ( COMMENT ON TABLE public.personal_access_tokens IS 'Personal Access Tokens for workers, CLI tools, and MCP clients'; +-- +-- Name: COLUMN personal_access_tokens.worker_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.personal_access_tokens.worker_id IS 'Optional binding to a specific device_workers.worker_id. Set by /api/me/devices/mint-child-token. When non-NULL, /api/workers/poll requires the request body''s worker_id to match.'; + -- -- Name: personal_access_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -3596,6 +3602,12 @@ CREATE INDEX idx_notification_targets_user_all ON public.notification_targets US CREATE INDEX idx_notification_targets_user_unread ON public.notification_targets USING btree (user_id, delivered_at DESC) WHERE (read_at IS NULL); +-- +-- Name: idx_personal_access_tokens_worker_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_personal_access_tokens_worker_id ON public.personal_access_tokens USING btree (worker_id) WHERE (worker_id IS NOT NULL); + -- -- Name: idx_runs_active_auth_per_profile; Type: INDEX; Schema: public; Owner: - -- @@ -3999,12 +4011,6 @@ CREATE INDEX personal_access_tokens_active_idx ON public.personal_access_tokens CREATE INDEX personal_access_tokens_organization_id_idx ON public.personal_access_tokens USING btree (organization_id); --- --- Name: idx_personal_access_tokens_worker_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_personal_access_tokens_worker_id ON public.personal_access_tokens USING btree (worker_id) WHERE (worker_id IS NOT NULL); - -- -- Name: personal_access_tokens_token_hash_idx; Type: INDEX; Schema: public; Owner: - -- diff --git a/packages/server/src/tools/admin/manage_feeds.ts b/packages/server/src/tools/admin/manage_feeds.ts index 8c400df40..61d2f7a39 100644 --- a/packages/server/src/tools/admin/manage_feeds.ts +++ b/packages/server/src/tools/admin/manage_feeds.ts @@ -153,10 +153,11 @@ async function handleListFeeds( const offset = args.offset ?? 0; // Build the filtered "page" of feeds first, then compute event_count in a - // single GROUP BY over the (connection_id, feed_key) pairs in that page. - // The previous shape ran a correlated `SELECT COUNT(*) FROM current_event_records` - // per row, which is O(N feeds) × an anti-join over the entire events table — - // ~880ms per feed on a busy connection. Batching collapses it to one scan. + // single GROUP BY restricted to the (connection_id, feed_key) tuples on + // that page. The previous shape ran a correlated + // `SELECT COUNT(*) FROM current_event_records` per row — O(N feeds) × + // an anti-join over the entire events table — ~880ms per feed on a busy + // connection. Batching collapses it to one scan. let pageQuery = sql` SELECT f.* FROM feeds f @@ -184,10 +185,14 @@ async function handleListFeeds( SELECT e.connection_id, e.feed_key, COUNT(*)::int AS event_count FROM events e WHERE e.organization_id = ${organizationId} - -- ANY(ARRAY(...)) keeps the planner on a single index scan per - -- distinct connection. IN (subquery) or a join causes Postgres to - -- re-scan the connection_id index per (connection, feed_key) pair. + -- ANY(ARRAY(...)) on each column lets the planner stay on + -- per-column index scans and intersect, rather than re-scanning + -- the connection_id index per (connection, feed_key) pair the + -- way IN (subquery) on a tuple would. The feed_key ANY narrows + -- the scan to the keys actually on this page; the final LEFT + -- JOIN drops any over-count from the cross-product. AND e.connection_id = ANY(ARRAY(SELECT DISTINCT connection_id FROM page)) + AND e.feed_key = ANY(ARRAY(SELECT DISTINCT feed_key FROM page WHERE feed_key IS NOT NULL)) AND NOT EXISTS (SELECT 1 FROM events n WHERE n.supersedes_event_id = e.id) GROUP BY e.connection_id, e.feed_key ) diff --git a/packages/web b/packages/web index 2d2f5bf25..ca12cd255 160000 --- a/packages/web +++ b/packages/web @@ -1 +1 @@ -Subproject commit 2d2f5bf253c130ea859982b8243cbf7a1a1719af +Subproject commit ca12cd255e48d0b945fe6033f5d6e0dec5d21541 From 3d42f82fd3805be1430e0953bd6911977e62b96c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sun, 17 May 2026 04:26:49 +0100 Subject: [PATCH 2/4] fix(schema): drop stray blank line so file matches dbmate up output --- db/schema.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/db/schema.sql b/db/schema.sql index 34fca3864..7bea93d5e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -4010,7 +4010,6 @@ CREATE INDEX personal_access_tokens_active_idx ON public.personal_access_tokens CREATE INDEX personal_access_tokens_organization_id_idx ON public.personal_access_tokens USING btree (organization_id); - -- -- Name: personal_access_tokens_token_hash_idx; Type: INDEX; Schema: public; Owner: - -- From 7074eb964e9ddbdc2cae74e6f9f0aae913bf4fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sun, 17 May 2026 04:27:23 +0100 Subject: [PATCH 3/4] chore(submodule): bump web for useFeeds prefetch gate (pi review of #803) --- packages/web | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web b/packages/web index ca12cd255..b01682708 160000 --- a/packages/web +++ b/packages/web @@ -1 +1 @@ -Subproject commit ca12cd255e48d0b945fe6033f5d6e0dec5d21541 +Subproject commit b01682708902e51fc33b3f68edbc0f4319836509 From fa2ba644dd0f9bdbc06573fb1d3e4844f7f9a17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sun, 17 May 2026 04:36:01 +0100 Subject: [PATCH 4/4] test(watchers): pass agent_id on watchers.create across integration tests --- .../integration/classifiers/classifiers-crud.test.ts | 3 +++ .../classifiers/classifiers-isolation.test.ts | 7 ++++++- .../integration/watchers/feedback-contract.test.ts | 7 ++++++- .../__tests__/integration/watchers/group-edit.test.ts | 7 ++++++- .../__tests__/integration/watchers/watchers-crud.test.ts | 9 +++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/server/src/__tests__/integration/classifiers/classifiers-crud.test.ts b/packages/server/src/__tests__/integration/classifiers/classifiers-crud.test.ts index 80497167e..0561d00c0 100644 --- a/packages/server/src/__tests__/integration/classifiers/classifiers-crud.test.ts +++ b/packages/server/src/__tests__/integration/classifiers/classifiers-crud.test.ts @@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { addUserToOrganization, + createTestAgent, createTestOrganization, createTestUser, } from '../../setup/test-fixtures'; @@ -36,6 +37,7 @@ describe('classifier CRUD', () => { })) as { entity: { id: number } }; entityId = entity.entity.id; + const agent = await createTestAgent({ organizationId: org.id, ownerUserId: user.id }); const w = (await owner.watchers.create({ entity_id: entityId, slug: 'cls-watcher', @@ -45,6 +47,7 @@ describe('classifier CRUD', () => { type: 'object', properties: { signal: { type: 'string' } }, }, + agent_id: agent.agentId, })) as { watcher_id: string }; watcherId = Number(w.watcher_id); }); diff --git a/packages/server/src/__tests__/integration/classifiers/classifiers-isolation.test.ts b/packages/server/src/__tests__/integration/classifiers/classifiers-isolation.test.ts index 429a6d395..d3846255b 100644 --- a/packages/server/src/__tests__/integration/classifiers/classifiers-isolation.test.ts +++ b/packages/server/src/__tests__/integration/classifiers/classifiers-isolation.test.ts @@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { createTestEvent } from '../../setup/test-fixtures'; +import { createTestAgent, createTestEvent } from '../../setup/test-fixtures'; import { TestWorkspace } from '../../setup/test-mcp-client'; const stubEmbedding = Array.from({ length: 768 }, () => 0); @@ -35,12 +35,17 @@ async function seedClassifier(workspace: TestWorkspace, slug: string): Promise { let owner: TestApiClient; let intruder: TestApiClient; let entityId: number; + let agentId: string; beforeAll(async () => { await cleanupTestDatabase(); @@ -30,6 +32,8 @@ describe('watcher CRUD', () => { userId: user.id, memberRole: 'owner', }); + const agent = await createTestAgent({ organizationId: org.id, ownerUserId: user.id }); + agentId = agent.agentId; const otherOrg = await createTestOrganization({ name: 'Watcher Other Org' }); const otherUser = await createTestUser({ email: 'watcher-other@test.com' }); @@ -59,6 +63,7 @@ describe('watcher CRUD', () => { properties: { launches: { type: 'array', items: { type: 'string' } } }, }, schedule: '0 9 * * *', + agent_id: agentId, })) as { watcher_id: string }; const watcherId = created.watcher_id; expect(watcherId).toBeDefined(); @@ -90,6 +95,7 @@ describe('watcher CRUD', () => { type: 'object', properties: { signals: { type: 'array', items: { type: 'string' } } }, }, + agent_id: agentId, })) as { watcher_id: string }; expect(created.watcher_id).toBeDefined(); @@ -109,6 +115,7 @@ describe('watcher CRUD', () => { name: 'No Org', prompt: 'should fail', extraction_schema: { type: 'object', properties: {} }, + agent_id: agentId, }) ).rejects.toThrow(/organization|entity_id/i); }); @@ -122,6 +129,7 @@ describe('watcher CRUD', () => { type: 'object', properties: { signals: { type: 'array', items: { type: 'string' } } }, }, + agent_id: agentId, })) as { watcher_id: string }; await expect(intruder.watchers.get(created.watcher_id)).rejects.toThrow( @@ -155,6 +163,7 @@ describe('watcher CRUD', () => { type: 'object', properties: { signal: { type: 'string' } }, }, + agent_id: agentId, })) as { watcher_id: string }; const member = owner.withAuth({ memberRole: 'member' });