mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI keeps its caches fresh from live events in `LiveUpdatesProvider` > - Each task activity event invalidates the whole `["issues", companyId]` query prefix > - That prefix also holds the `@task` mention pool: the 100 most recently updated tasks, about 315 KB per response > - A chat turn emits many comment, status and run events, so an open task page downloads that list again for almost every event > - This pull request keeps the pool fresh from push events without a refetch per event: a closed server flag marks real membership or title changes, progress on pooled tasks reorders the cache in place, and other progress refetches at most once per 30 seconds > - The benefit is less bandwidth and CPU for users with an open task or chat, and smaller eval traces ## Linked Issues or Issue Description **What happened** While a task chat page is open, the browser repeats `GET /api/companies/:id/issues?limit=100&sortField=updated&sortDir=desc` (about 315 KB each). One 10-minute chat-eval attempt recorded about 194 of these requests. The trigger is `invalidateActivityQueries` in `ui/src/context/LiveUpdatesProvider.tsx`. For every `entityType: "issue"` activity event, it invalidates `queryKeys.issues.list(companyId)`. That prefix matches `queryKeys.issues.mentionPool(companyId)`, which `IssueDetail` (and `NewIssueDialog`) keep active for the `@task` picker. Invalidation refetches active queries and ignores `staleTime`. **Expected behavior** Comments, run progress and status changes on any task do not download the full mention pool again. The pool still updates when a task that the picker can show is created, deleted or renamed. **Steps to reproduce** 1. Run `pnpm dev` and create a company with about 140 tasks. 2. Open a task page, for example `/REF/issues/REF-1`. 3. Every 4 seconds for 15 rounds, post comments on the open task, on a task in the top 100, and on a task outside the top 100, and change the open task's priority (60 events). 4. Count requests to `issues?limit=100&sortField=updated`. Before this change there are 15 (about 3.3 MB). After this change there are 2 (about 480 KB). ## What Changed - New shared helper `issueActivityChangesTaskIdentity` (`packages/shared/src/issue-activity-identity.ts`). It is true for a created, deleted, hidden, unhidden or renamed task. - `server/src/realtime/live-events-ws.ts`: task `activity.logged` events now carry the closed boolean `taskIdentityChanged`. Authenticated sockets still get no `details`; the flag carries no task content. - `ui/src/context/LiveUpdatesProvider.tsx` refreshes the mention pool this way: - The task identity flag is true (or full details show a create, delete, hide, unhide or rename): the pool refetches at once. - Other events on a task already in the pool: the task moves to the front of the cached list. There is no request. The freshness clock does not change, and a pending invalidation is never overwritten. - Other events on a task outside the pool: one trailing refetch at most every 30 seconds, because the task can now be in the top 100. - The picker is closed: the pool is only marked stale. - `ui/src/lib/query-invalidation-batcher.ts`: new `excludingQueryKeys()` keyed exclusion predicate. The batcher coalesces it and uses it for prefix coverage. The broad `["issues", companyId]` invalidation uses it to skip the pool, so a burst is again one broad refresh per batch. - Tests: `ui/src/context/LiveUpdatesProvider.mention-pool.test.ts` (authenticated event shape, hide and unhide, the 30-second window, reorder safety, a burst through the batcher), new batcher tests, `packages/shared/src/issue-activity-identity.test.ts`, and socket assertions in `server/src/__tests__/live-events-toast.test.ts`. ## Verification - `cd ui && npx vitest run src/context/LiveUpdatesProvider src/lib/query-invalidation-batcher`: 231 tests pass. - `cd server && npx vitest run src/__tests__/live-events-toast.test.ts`: 11 tests pass. In authenticated mode the socket keeps `details` off and sends `taskIdentityChanged` (false for routine updates, true for a create, rename or hide). - `cd packages/shared && npx vitest run src/issue-activity-identity.test.ts`: 14 tests pass. - The new UI tests fail against the first commit of this PR: the authenticated burst, hide, unhide, the 30-second window and the batcher burst. - `tsc --noEmit` for ui, server and shared: no new errors. - Browser measurement: a local `pnpm dev` instance (local trusted), headless Chromium on a task page, 141 tasks, 60 events over about 63 seconds (the reproduction steps above): - Before (master UI): **15** mention-pool requests, about 3.3 MB. - After: **2** requests, about 480 KB (one per 30-second window). - Freshness after the fix: one rename, hide, unhide or create each caused **1** refetch. A priority change caused **0**. - I did not run a browser session in authenticated mode. The authenticated event shape is covered by the socket test and the UI unit tests. ## Risks - A task that moves into the top 100 can be missing from the `@task` picker for up to 30 seconds. - The cached order is approximate between refetches: a moved task goes to the front, but its `updatedAt` value is not changed. - Other queries under `["issues", companyId]` (descendants, created-from, summaries, onboarding first task) still refresh for each event batch. They are small, but they can get the same treatment later. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
17 KiB
TypeScript
329 lines
17 KiB
TypeScript
import { createServer } from "node:http";
|
|
import { once } from "node:events";
|
|
import { getTableName, type SQL } from "drizzle-orm";
|
|
import { PgDialect } from "drizzle-orm/pg-core";
|
|
import { activityToastIdentity } from "../realtime/activity-toast-identity.js";
|
|
import { WebSocket } from "ws";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { setupLiveEventsWebSocketServer } from "../realtime/live-events-ws.js";
|
|
import { publishLiveEvent } from "../services/live-events.js";
|
|
import { taskToastEligible } from "../realtime/task-toast-eligibility.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
|
|
vi.mock("../realtime/task-toast-eligibility.js", () => ({ taskToastEligible: vi.fn(async () => false) }));
|
|
|
|
vi.mock("../services/authorization.js", () => ({
|
|
authorizationService: () => ({ decide: async ({ resource }: { resource: { issueId: string } }) => ({ allowed: resource.issueId !== "hidden" }) }),
|
|
}));
|
|
vi.mock("../services/heartbeat-run-privacy.js", () => ({
|
|
getHeartbeatRunReadDecisions: async (_db: unknown, _access: unknown, _actor: unknown, runs: { issueId?: string }[]) =>
|
|
runs.map(run => run.issueId !== "hidden"),
|
|
}));
|
|
vi.mock("../services/case-access.js", () => ({
|
|
canActorReadCase: async (_db: unknown, _actor: unknown, record: { id: string }) => record.id !== "hidden-case",
|
|
}));
|
|
function database(onPredicate: (kind: "where" | "join", condition: SQL) => void = () => {}) {
|
|
return { select: (fields: Record<string, unknown>) => {
|
|
let table = "";
|
|
const query = {
|
|
from: (value: Parameters<typeof getTableName>[0]) => { table = getTableName(value); return query; },
|
|
innerJoin: (_table: unknown, condition: SQL) => { onPredicate("join", condition); return query; },
|
|
where: (condition: SQL) => { onPredicate("where", condition); return query; },
|
|
limit: async () => {
|
|
if (table === "issues") return [{ id: "task", identifier: "PAP-1234" }];
|
|
if (table === "company_memberships") return fields.name ? [{ name: "Alex Example", image: "/alex.png" }] : [{ id: "membership" }];
|
|
if (table === "agents") return [{ name: "Coder", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "bubblegum-sky" } }];
|
|
return [];
|
|
},
|
|
};
|
|
return query;
|
|
} };
|
|
}
|
|
describe("authenticated activity notifications", () => {
|
|
it("retains live delivery idle work until the identifier read settles after the last socket closes", async () => {
|
|
const baseline = idleWorkSnapshot().active;
|
|
let release!: () => void, entered!: () => void;
|
|
const pending = new Promise<void>(resolve => { release = resolve; });
|
|
const started = new Promise<void>(resolve => { entered = resolve; });
|
|
const db = database(), select = db.select;
|
|
db.select = fields => {
|
|
const query = select(fields);
|
|
if (fields.identifier) {
|
|
const limit = query.limit;
|
|
query.limit = async () => { entered(); await pending; return limit(); };
|
|
}
|
|
return query;
|
|
};
|
|
const server = createServer();
|
|
const wss = setupLiveEventsWebSocketServer(server, db as never, { deploymentMode: "local_trusted" });
|
|
const closed = new Promise<void>(resolve => wss.on("connection", socket => socket.on("close", resolve)));
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
|
|
try {
|
|
await once(socket, "open");
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(baseline));
|
|
// System activity has no actor lookup: this gate is the new identifier SQL.
|
|
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
|
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "system",
|
|
} });
|
|
await started;
|
|
socket.terminate();
|
|
await closed;
|
|
expect(wss.clients.size).toBe(0);
|
|
expect(idleWorkSnapshot().active).toBe(baseline + 1);
|
|
release();
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(baseline));
|
|
} finally {
|
|
release();
|
|
socket.terminate();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(baseline));
|
|
}
|
|
});
|
|
|
|
it.each(["local_trusted", "authenticated"] as const)("keeps authorized notifications when optional identifier reads fail (%s)", async deploymentMode => {
|
|
const db = database();
|
|
const select = db.select;
|
|
const identifierRead = vi.fn();
|
|
db.select = fields => {
|
|
if (fields.identifier) {
|
|
identifierRead();
|
|
if (identifierRead.mock.calls.length <= 3) throw new Error("Identifier lookup unavailable");
|
|
}
|
|
return select(fields);
|
|
};
|
|
const server = createServer();
|
|
setupLiveEventsWebSocketServer(server, db as never, { deploymentMode,
|
|
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
|
|
try {
|
|
await once(socket, "open");
|
|
const messages: Array<{ payload: Record<string, unknown> }> = [];
|
|
const delivered = new Promise<void>(resolve => socket.on("message", data => {
|
|
const event = JSON.parse(data.toString());
|
|
messages.push(event);
|
|
if (event.payload.entityId === "after-failure") resolve();
|
|
}));
|
|
const activity = (entityId: string, actorType: string) => publishLiveEvent({
|
|
companyId: "company", type: "activity.logged", payload: {
|
|
action: "issue.updated", entityType: "issue", entityId, actorType,
|
|
actorId: actorType === "user" ? "viewer" : actorType === "agent" ? "agent" : null,
|
|
issueIdentifier: "SPOOF-1", details: { secret: "private task details" },
|
|
},
|
|
});
|
|
if (deploymentMode === "authenticated") activity("hidden", "system");
|
|
for (const actorType of ["system", "agent", "user"]) {
|
|
activity("visible", actorType);
|
|
}
|
|
activity("after-failure", "system");
|
|
await delivered;
|
|
expect(messages).toHaveLength(4);
|
|
expect(identifierRead).toHaveBeenCalledTimes(4);
|
|
for (const [index, actorType] of ["system", "agent", "user"].entries()) {
|
|
const event = messages[index];
|
|
expect(event.payload).toMatchObject({ entityId: "visible", actorType, issueIdentifier: null });
|
|
if (actorType === "agent") expect(event.payload.actorName).toBe("Coder");
|
|
if (actorType === "user") expect(event.payload.actorName).toBe("Alex Example");
|
|
if (deploymentMode === "authenticated") expect(event.payload).not.toHaveProperty("details");
|
|
// Routine updates are not task identity changes.
|
|
expect(event.payload.taskIdentityChanged).toBe(false);
|
|
}
|
|
expect(messages[3].payload.issueIdentifier).toBe("PAP-1234");
|
|
} finally {
|
|
socket.terminate();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("preserves only an authorized handoff task identity for empty case-panel refresh", async () => {
|
|
const server = createServer();
|
|
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated",
|
|
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
|
|
try {
|
|
await once(socket, "open");
|
|
const messages: Array<{ payload: Record<string, unknown> }> = [];
|
|
socket.on("message", data => messages.push(JSON.parse(data.toString())));
|
|
const activity = (entityId: string, issueId: string, action = "case.handoff_applied") => publishLiveEvent({
|
|
companyId: "company", type: "activity.logged", payload: {
|
|
action, entityType: "case", entityId, issueId,
|
|
details: { instructions: "private handoff instructions", phase: "verification" },
|
|
},
|
|
});
|
|
activity("hidden-case", "visible");
|
|
activity("visible-case", "hidden");
|
|
const first = once(socket, "message");
|
|
activity("visible-case", "visible");
|
|
await first;
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].payload).toEqual({ action: "case.handoff_applied", entityType: "case", entityId: "visible-case", issueId: "visible", actorType: null, actorId: null });
|
|
const second = once(socket, "message");
|
|
activity("visible-case", "visible", "case.updated");
|
|
await second;
|
|
expect(messages[1].payload).toEqual({ action: "case.updated", entityType: "case", entityId: "visible-case", actorType: null, actorId: null });
|
|
} finally {
|
|
socket.terminate();
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("uses the persisted local-board user identity for local task eligibility", async () => {
|
|
vi.mocked(taskToastEligible).mockImplementation(async (_db, _event, userId) => userId === "local-board");
|
|
const server = createServer();
|
|
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "local_trusted" });
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
|
|
try {
|
|
await once(socket, "open");
|
|
const delivery = once(socket, "message");
|
|
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
|
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "agent", actorId: "agent",
|
|
} });
|
|
const [data] = await delivery;
|
|
expect(JSON.parse(data.toString()).toastEligible).toBe(true);
|
|
expect(JSON.parse(data.toString()).payload.issueIdentifier).toBe("PAP-1234");
|
|
expect(taskToastEligible).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "local-board");
|
|
} finally {
|
|
socket.terminate();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false);
|
|
}
|
|
});
|
|
|
|
it("projects eligibility per viewer and keeps authorized updates on lookup failure", async () => {
|
|
vi.mocked(taskToastEligible).mockImplementation(async (_db, event, userId) => {
|
|
if (event.payload.entityId === "lookup-failure") throw new Error("Relevance unavailable");
|
|
return userId === "alice";
|
|
});
|
|
const server = createServer();
|
|
setupLiveEventsWebSocketServer(server, database() as never, {
|
|
deploymentMode: "authenticated",
|
|
resolveCloudActor: async (req) => ({ userId: String(req.headers["x-test-viewer"]), companyIds: ["company"] }),
|
|
});
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`;
|
|
const alice = new WebSocket(url, { headers: { "x-test-viewer": "alice" } });
|
|
const bob = new WebSocket(url, { headers: { "x-test-viewer": "bob" } });
|
|
try {
|
|
await Promise.all([once(alice, "open"), once(bob, "open")]);
|
|
for (const entityId of ["visible", "lookup-failure"]) {
|
|
const deliveries = Promise.all([once(alice, "message"), once(bob, "message")]);
|
|
const original = publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
|
action: "issue.comment_added", entityType: "issue", entityId, actorType: "agent", actorId: "agent",
|
|
} });
|
|
const [a, b] = (await deliveries).map(([data]) => JSON.parse(data.toString()));
|
|
expect(a.toastEligible).toBe(entityId === "visible");
|
|
expect(b.toastEligible).toBe(false);
|
|
expect(a.payload.entityId).toBe(entityId);
|
|
expect(b.payload.entityId).toBe(entityId);
|
|
expect(a.payload.issueIdentifier).toBe("PAP-1234");
|
|
expect(b.payload.issueIdentifier).toBe("PAP-1234");
|
|
expect(original).not.toHaveProperty("toastEligible");
|
|
}
|
|
} finally {
|
|
alice.terminate(); bob.terminate();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false);
|
|
}
|
|
});
|
|
|
|
it.each(["authenticated", "local_trusted"] as const)("keeps task and actor identity for %s without exposing hidden tasks or copying private details", async (deploymentMode) => {
|
|
const server = createServer();
|
|
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode,
|
|
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
|
|
try {
|
|
await once(socket, "open");
|
|
const messages: Array<{ payload: Record<string, unknown> }> = [];
|
|
socket.on("message", (data) => messages.push(JSON.parse(data.toString())));
|
|
const activity = (entityId: string, actorType = "user") => publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
|
action: "issue.created", entityType: "issue", entityId, actorType, actorId: actorType === "user" ? "viewer" : "agent",
|
|
issueIdentifier: "SPOOF-1",
|
|
details: { bodySnippet: "private comment", secret: "not for the socket" },
|
|
} });
|
|
if (deploymentMode === "authenticated") activity("hidden");
|
|
const first = once(socket, "message");
|
|
activity("visible");
|
|
await first;
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].payload).toMatchObject({ action: "issue.created", entityType: "issue", entityId: "visible", issueIdentifier: "PAP-1234", actorType: "user", actorId: "viewer", actorName: "Alex Example", actorImage: "/alex.png" });
|
|
if (deploymentMode === "authenticated") expect(messages[0].payload).not.toHaveProperty("details");
|
|
const second = once(socket, "message");
|
|
activity("visible", "agent");
|
|
await second;
|
|
expect(messages[1].payload).toMatchObject({ issueIdentifier: "PAP-1234", actorName: "Coder", actorType: "agent", actorId: "agent", actorAppearance: { paletteId: "bubblegum-sky" } });
|
|
if (deploymentMode === "authenticated") expect(messages[1].payload).not.toHaveProperty("details");
|
|
expect(messages[0].payload.taskIdentityChanged).toBe(true);
|
|
const third = once(socket, "message");
|
|
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
|
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "user", actorId: "viewer",
|
|
details: { title: "Secret new title", hiddenAt: null },
|
|
} });
|
|
await third;
|
|
// A rename or hide is flagged with a closed boolean; its content stays off the socket.
|
|
expect(messages[2].payload.taskIdentityChanged).toBe(true);
|
|
if (deploymentMode === "authenticated") expect(JSON.stringify(messages[2].payload)).not.toContain("Secret new title");
|
|
} finally {
|
|
socket.terminate();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
describe("activity profile query boundaries", () => {
|
|
it("reads the task identifier within its company even when there is no actor", async () => {
|
|
const predicates: { sql: string; params: unknown[] }[] = [];
|
|
const db = database((_kind, condition) => {
|
|
const { sql, params } = new PgDialect().sqlToQuery(condition);
|
|
predicates.push({ sql, params });
|
|
});
|
|
expect(await activityToastIdentity(db as never, "company-A", {
|
|
action: "issue.updated", entityType: "issue", entityId: "task-A", actorType: "system",
|
|
})).toMatchObject({ issueIdentifier: "PAP-1234" });
|
|
expect(predicates).toEqual([
|
|
{ sql: '("issues"."company_id" = $1 and "issues"."id" = $2)', params: ["company-A", "task-A"] },
|
|
]);
|
|
});
|
|
|
|
it("requires the exact company, human actor, and active membership before reading a person profile", async () => {
|
|
const predicates: { kind: string; sql: string; params: unknown[] }[] = [];
|
|
const db = database((kind, condition) => {
|
|
const { sql, params } = new PgDialect().sqlToQuery(condition);
|
|
predicates.push({ kind, sql, params });
|
|
});
|
|
await activityToastIdentity(db as never, "company-A", {
|
|
action: "issue.created", actorType: "user", actorId: "person-A",
|
|
});
|
|
expect(predicates).toEqual([
|
|
{ kind: "join", sql: '"user"."id" = "company_memberships"."principal_id"', params: [] },
|
|
{ kind: "where", sql: '("company_memberships"."company_id" = $1 and "company_memberships"."principal_type" = $2 and "company_memberships"."principal_id" = $3 and "company_memberships"."status" = $4)',
|
|
params: ["company-A", "user", "person-A", "active"] },
|
|
]);
|
|
});
|
|
|
|
it("requires both the exact company and agent ID before reading an agent profile", async () => {
|
|
const predicates: { kind: string; sql: string; params: unknown[] }[] = [];
|
|
const db = database((kind, condition) => {
|
|
const { sql, params } = new PgDialect().sqlToQuery(condition);
|
|
predicates.push({ kind, sql, params });
|
|
});
|
|
await activityToastIdentity(db as never, "company-A", {
|
|
action: "issue.created", actorType: "agent", actorId: "agent-A",
|
|
});
|
|
expect(predicates).toEqual([
|
|
{ kind: "where", sql: '("agents"."company_id" = $1 and "agents"."id" = $2)',
|
|
params: ["company-A", "agent-A"] },
|
|
]);
|
|
});
|
|
});
|