Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { beforeEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | import { withQueryContext } from "@executor-js/fumadb/query"; | 3 | import { withQueryContext } from "@executor-js/fumadb/query"; |
| 4 | 4 | ||
| @@ -7,7 +7,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./i · 2 unmodified lines | |||
| 7 | import type { ExecutorOwnerPolicyContext } from "./owner-policy"; | 7 | import type { ExecutorOwnerPolicyContext } from "./owner-policy"; |
| 8 | import { definePlugin } from "./plugin"; | 8 | import { definePlugin } from "./plugin"; |
| 9 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | 9 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; |
| 10 | import { touchSubject } from "./subject-registry"; | 10 | import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; |
| 11 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 11 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 12 | 12 | ||
| 13 | // `touchSubject` is the only writer of the `subject` table. Written against the | 13 | // `touchSubject` is the only writer of the `subject` table. Written against the |
| @@ -48,6 +48,14 @@ const storedSubjects = ( · 34 unmodified lines | |||
| 48 | })); | 48 | })); |
| 49 | }); | 49 | }); |
| 50 | 50 | ||
| 51 | // `touchSubject` remembers, per process, which principals it already filed | ||
| 52 | // inside the throttle window. Every case below reuses the same tenant/principal | ||
| 53 | // against a FRESH database, so that memory has to be dropped between them or a | ||
| 54 | // later case would skip the query its assertion depends on. | ||
| 55 | beforeEach(() => { | ||
| 56 | resetSubjectTouchCache(); | ||
| 57 | }); | ||
| 58 | |||
| 51 | describe("touchSubject", () => { | 59 | describe("touchSubject", () => { |
| 52 | it.effect("creates the row on first sight", () => | 60 | it.effect("creates the row on first sight", () => |
| 53 | withDb((db) => | 61 | withDb((db) => |
| @@ -75,8 +83,8 @@ describe("touchSubject", () => { · 21 unmodified lines | |||
| 75 | }); | 83 | }); |
| 76 | const [first] = yield* storedSubjects(db); | 84 | const [first] = yield* storedSubjects(db); |
| 77 | 85 | ||
| 78 | // The hot path: N requests in quick succession must cost N reads and | 86 | // The hot path: N requests in quick succession must cost zero writes, |
| 79 | // zero writes, so the persisted stamp is unchanged afterwards. | 87 | // so the persisted stamp is unchanged afterwards. |
| 80 | yield* Effect.forEach([1, 2, 3], () => | 88 | yield* Effect.forEach([1, 2, 3], () => |
| 81 | touchSubject(db.db, { | 89 | touchSubject(db.db, { |
| 82 | tenant: TENANT, | 90 | tenant: TENANT, |
| @@ -108,6 +116,11 @@ describe("touchSubject", () => { · 25 unmodified lines | |||
| 108 | set: { last_seen_at: BigInt(stale) }, | 116 | set: { last_seen_at: BigInt(stale) }, |
| 109 | }), | 117 | }), |
| 110 | ); | 118 | ); |
| 119 | // Backdating the row stands in for elapsed time, and elapsed time ages | ||
| 120 | // the process-local sighting memory too — otherwise this would assert | ||
| 121 | // against a clock the memory never saw. Expiring it is what the passage | ||
| 122 | // of time does; the assertion below is unchanged. | ||
| 123 | resetSubjectTouchCache(); | ||
| 111 | 124 | ||
| 112 | yield* touchSubject(db.db, { | 125 | yield* touchSubject(db.db, { |
| 113 | tenant: TENANT, | 126 | tenant: TENANT, |
| @@ -122,6 +135,125 @@ describe("touchSubject", () => { · 8 unmodified lines | |||
| 122 | ), | 135 | ), |
| 123 | ); | 136 | ); |
| 124 | 137 | ||
| 138 | it.effect("issues no query at all for a repeat sighting inside the throttle", () => | ||
| 139 | withDb((db) => | ||
| 140 | Effect.gen(function* () { | ||
| 141 | // THE regression this guards: the request seam runs `touchSubject` on | ||
| 142 | // every HTTP request and MCP session, so a sighting that still costs an | ||
| 143 | // indexed read per request adds a round-trip to every request in the | ||
| 144 | // product. Dropping the table after the first sighting makes any | ||
| 145 | // further query fail loudly — and `touchSubject` swallows storage | ||
| 146 | // failures, so a surviving query would be invisible in the row state. | ||
| 147 | // Counting statements is the only way to see it. | ||
| 148 | yield* touchSubject(db.db, { | ||
| 149 | tenant: TENANT, | ||
| 150 | externalId: "user_a", | ||
| 151 | lastSeenThrottleMs: THROTTLE_MS, | ||
| 152 | }); | ||
| 153 | |||
| 154 | let queries = 0; | ||
| 155 | const client = db.client as { execute: (...args: never[]) => unknown }; | ||
| 156 | const execute = client.execute.bind(client) as (...args: never[]) => unknown; | ||
| 157 | client.execute = (...args: never[]) => { | ||
| 158 | queries += 1; | ||
| 159 | return execute(...args); | ||
| 160 | }; | ||
| 161 | |||
| 162 | yield* Effect.forEach([1, 2, 3], () => | ||
| 163 | touchSubject(db.db, { | ||
| 164 | tenant: TENANT, | ||
| 165 | externalId: "user_a", | ||
| 166 | lastSeenThrottleMs: THROTTLE_MS, | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 170 | client.execute = execute as typeof client.execute; | ||
| 171 | expect(queries).toBe(0); | ||
| 172 | }), | ||
| 173 | ), | ||
| 174 | ); | ||
| 175 | |||
| 176 | it.effect("re-reads once the remembered sighting ages past the throttle", () => | ||
| 177 | withDb((db) => | ||
| 178 | Effect.gen(function* () { | ||
| 179 | // The memory must expire with the throttle, not pin a principal as | ||
| 180 | // "recently seen" forever: `last_seen_at` still has to advance for a | ||
| 181 | // principal who keeps calling across windows. | ||
| 182 | yield* touchSubject(db.db, { | ||
| 183 | tenant: TENANT, | ||
| 184 | externalId: "user_a", | ||
| 185 | lastSeenThrottleMs: THROTTLE_MS, | ||
| 186 | }); | ||
| 187 | const [first] = yield* storedSubjects(db); | ||
| 188 | |||
| 189 | // A zero throttle makes every remembered sighting instantly stale, | ||
| 190 | // which is what a caller crossing the window observes. | ||
| 191 | yield* touchSubject(db.db, { | ||
| 192 | tenant: TENANT, | ||
| 193 | externalId: "user_a", | ||
| 194 | lastSeenThrottleMs: 0, | ||
| 195 | }); | ||
| 196 | |||
| 197 | const rows = yield* storedSubjects(db); | ||
| 198 | expect(rows).toHaveLength(1); | ||
| 199 | expect(rows[0]?.lastSeenMs).toBeGreaterThanOrEqual(first?.lastSeenMs ?? 0); | ||
| 200 | }), | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | |||
| 204 | it.effect("remembers a first sighting per tenant, not per principal", () => | ||
| 205 | withDb((db) => | ||
| 206 | Effect.gen(function* () { | ||
| 207 | // One account acting in two orgs is two rows. A memory keyed on the | ||
| 208 | // principal alone would file the first tenant and skip the second, | ||
| 209 | // losing the row the other tenant's admin view depends on. | ||
| 210 | yield* touchSubject(db.db, { | ||
| 211 | tenant: TENANT, | ||
| 212 | externalId: "user_a", | ||
| 213 | lastSeenThrottleMs: THROTTLE_MS, | ||
| 214 | }); | ||
| 215 | yield* touchSubject(db.db, { | ||
| 216 | tenant: OTHER_TENANT, | ||
| 217 | externalId: "user_a", | ||
| 218 | lastSeenThrottleMs: THROTTLE_MS, | ||
| 219 | }); | ||
| 220 | |||
| 221 | expect((yield* storedSubjects(db, OTHER_TENANT)).map((row) => row.externalId)).toEqual([ | ||
| 222 | "user_a", | ||
| 223 | ]); | ||
| 224 | }), | ||
| 225 | ), | ||
| 226 | ); | ||
| 227 | |||
| 228 | it.effect("does not remember a sighting that failed to persist", () => | ||
| 229 | withDb((db) => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | // A storage failure is swallowed so the request survives. It must not | ||
| 232 | // also be recorded as a successful sighting, or the principal would | ||
| 233 | // stay unfiled for a whole throttle window after the fault cleared. | ||
| 234 | yield* Effect.promise(() => db.client.execute("DROP TABLE subject")); | ||
| 235 | yield* touchSubject(db.db, { | ||
| 236 | tenant: TENANT, | ||
| 237 | externalId: "user_a", | ||
| 238 | lastSeenThrottleMs: THROTTLE_MS, | ||
| 239 | }); | ||
| 240 | |||
| 241 | yield* Effect.promise(() => | ||
| 242 | db.client.execute( | ||
| 243 | "CREATE TABLE subject (row_id text PRIMARY KEY NOT NULL, tenant text NOT NULL, external_id text NOT NULL, created_at integer NOT NULL, last_seen_at blob, status text)", | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | yield* touchSubject(db.db, { | ||
| 247 | tenant: TENANT, | ||
| 248 | externalId: "user_a", | ||
| 249 | lastSeenThrottleMs: THROTTLE_MS, | ||
| 250 | }); | ||
| 251 | |||
| 252 | expect((yield* storedSubjects(db)).map((row) => row.externalId)).toEqual(["user_a"]); | ||
| 253 | }), | ||
| 254 | ), | ||
| 255 | ); | ||
| 256 | |||
| 125 | it.effect("records nothing for a pure-org executor", () => | 257 | it.effect("records nothing for a pure-org executor", () => |
| 126 | withDb((db) => | 258 | withDb((db) => |
| 127 | Effect.gen(function* () { | 259 | Effect.gen(function* () { |
Binary file not shown.