1- import { describe , expect , it } from "@effect/vitest" ;
1+ import { beforeEach , describe , expect , it } from "@effect/vitest" ;
22import { Effect } from "effect" ;
33import { withQueryContext } from "@executor-js/fumadb/query" ;
44
@@ -7,7 +7,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./i
77import type { ExecutorOwnerPolicyContext } from "./owner-policy" ;
88import { definePlugin } from "./plugin" ;
99import { createSqliteTestFumaDb , type SqliteTestFumaDb } from "./sqlite-test-db" ;
10- import { touchSubject } from "./subject-registry" ;
10+ import { resetSubjectTouchCache , touchSubject } from "./subject-registry" ;
1111import { makeTestWorkspaceHarness , memoryCredentialsPlugin } from "./test-config" ;
1212
1313// `touchSubject` is the only writer of the `subject` table. Written against the
@@ -48,6 +48,14 @@ const storedSubjects = (
4848 } ) ) ;
4949 } ) ;
5050
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+
5159describe ( "touchSubject" , ( ) => {
5260 it . effect ( "creates the row on first sight" , ( ) =>
5361 withDb ( ( db ) =>
@@ -75,8 +83,8 @@ describe("touchSubject", () => {
7583 } ) ;
7684 const [ first ] = yield * storedSubjects ( db ) ;
7785
78- // The hot path: N requests in quick succession must cost N reads and
79- // zero writes, so the persisted stamp is unchanged afterwards.
86+ // The hot path: N requests in quick succession must cost zero writes,
87+ // so the persisted stamp is unchanged afterwards.
8088 yield * Effect . forEach ( [ 1 , 2 , 3 ] , ( ) =>
8189 touchSubject ( db . db , {
8290 tenant : TENANT ,
@@ -108,6 +116,11 @@ describe("touchSubject", () => {
108116 set : { last_seen_at : BigInt ( stale ) } ,
109117 } ) ,
110118 ) ;
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 ( ) ;
111124
112125 yield * touchSubject ( db . db , {
113126 tenant : TENANT ,
@@ -122,6 +135,125 @@ describe("touchSubject", () => {
122135 ) ,
123136 ) ;
124137
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+
125257 it . effect ( "records nothing for a pure-org executor" , ( ) =>
126258 withDb ( ( db ) =>
127259 Effect . gen ( function * ( ) {
0 commit comments