import { CURRENT_API_VERSION } from "~/api/versions"; import { NativeRealtimeClient, type RealtimeListEnvironment, } from "~/services/realtime/nativeRealtimeClient.server"; import { type RealtimeRunRow } from "~/services/realtime/electricStreamProtocol.server"; import { EnvChangeRouter } from "~/services/realtime/envChangeRouter.server"; import { setTimeout as sleep } from "node:timers/promises"; import { describe, expect, it, vi } from "vitest"; const ENV: RealtimeListEnvironment = { id: "env_1", organizationId: "org_1", projectId: "proj_1" }; function row(id: string): RealtimeRunRow { // Only id/createdAt/updatedAt are read directly; the rest serialize to null. return { id, createdAt: new Date("2026-06-07T09:00:00.000Z"), updatedAt: new Date("2026-06-07T10:00:00.000Z"), } as unknown as RealtimeRunRow; } function makeClient(overrides: Record = {}) { const resolveSpy = vi.fn(async () => ["run_1", "run_2"]); const hydrateSpy = vi.fn(async (_env: string, ids: string[]) => ids.map(row)); const client = new NativeRealtimeClient({ runReader: { getRunById: async () => null, hydrateByIds: hydrateSpy } as any, runListResolver: { resolveMatchingRunIds: resolveSpy } as any, // No-op source: live polls never get a router wake, so they fall through to the // backstop full-resolve — which is what the live tests below assert on. router: new EnvChangeRouter({ source: { subscribeToEnv: () => () => {} }, hydrator: { hydrateByIds: hydrateSpy }, replayWindowMs: 0, unsubscribeLingerMs: 0, }), limiter: { incrementAndCheck: async () => true, decrement: async () => {} } as any, cachedLimitProvider: { getCachedLimit: async () => 100 }, maximumCreatedAtFilterAgeMs: 24 * 60 * 60 * 1000, runSetResolveCacheTtlMs: 5_000, ...overrides, }); return { client, resolveSpy, hydrateSpy }; } // streamBatch with offset=-1 takes the snapshot path, which calls the coalescing // resolve+hydrate directly (no concurrency slot / subscription needed). function snapshot(client: NativeRealtimeClient, batchId: string, skipColumns?: string) { const skip = skipColumns ? `&skipColumns=${skipColumns}` : ""; return client.streamBatch( `http://localhost:3030/realtime/v1/batches/${batchId}?offset=-1${skip}`, ENV, batchId, CURRENT_API_VERSION, undefined, "1.0.0" ); } // Tag-list snapshot (offset=-1) — exercises the createdAt bucketing + cache key. function snapshotTag(client: NativeRealtimeClient, tags: string[]) { return client.streamRuns( "http://localhost:3030/realtime/v1/runs?offset=-1", ENV, { tags }, CURRENT_API_VERSION, undefined, "1.0.0" ); } describe("NativeRealtimeClient run-set resolve coalescing + cache", () => { it("coalesces concurrent same-filter resolves into one ClickHouse + Postgres query", async () => { const { client, resolveSpy, hydrateSpy } = makeClient(); let release!: (ids: string[]) => void; const gate = new Promise((resolve) => { release = resolve; }); resolveSpy.mockReturnValueOnce(gate); const p1 = snapshot(client, "batch_1"); const p2 = snapshot(client, "batch_1"); release(["run_1"]); await Promise.all([p1, p2]); expect(resolveSpy).toHaveBeenCalledTimes(1); expect(hydrateSpy).toHaveBeenCalledTimes(1); }); it("serves a second same-filter request from the cache within the TTL", async () => { const { client, resolveSpy, hydrateSpy } = makeClient(); await snapshot(client, "batch_1"); await snapshot(client, "batch_1"); expect(resolveSpy).toHaveBeenCalledTimes(1); expect(hydrateSpy).toHaveBeenCalledTimes(1); }); it("does not share the cache across different filters", async () => { const { client, resolveSpy } = makeClient(); await snapshot(client, "batch_1"); await snapshot(client, "batch_2"); expect(resolveSpy).toHaveBeenCalledTimes(2); }); it("re-queries after the cache TTL expires", async () => { vi.useFakeTimers({ toFake: ["Date"] }); try { const { client, resolveSpy } = makeClient({ runSetResolveCacheTtlMs: 1_000 }); await snapshot(client, "batch_1"); vi.advanceTimersByTime(1_001); await snapshot(client, "batch_1"); expect(resolveSpy).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); } }); it("passes the client's skipColumns through to the hydrator (column projection)", async () => { const { client, hydrateSpy } = makeClient(); await snapshot(client, "batch_1", "payload,output"); expect(hydrateSpy).toHaveBeenCalledWith("env_1", expect.any(Array), ["payload", "output"]); }); it("reports resolve outcomes (miss then hit) to the metrics hook", async () => { const results: string[] = []; const { client } = makeClient({ onRunSetResolve: (r: string) => results.push(r) }); await snapshot(client, "batch_1"); await snapshot(client, "batch_1"); expect(results).toEqual(["miss", "hit"]); }); it("mints a distinct batch handle per connection and echoes a client-provided one", async () => { const { client } = makeClient(); // Two subscribers to the SAME batch must never share a handle (the working-set // cache is keyed by it; sharing lets one suppress the other's deltas forever). const res1 = await snapshot(client, "batch_1"); const res2 = await snapshot(client, "batch_1"); const h1 = res1.headers.get("electric-handle"); const h2 = res2.headers.get("electric-handle"); expect(h1).toBeTruthy(); expect(h1).not.toBe(h2); // Catch-up under an existing handle keeps it. const res3 = await client.streamBatch( `http://localhost:3030/realtime/v1/batches/batch_1?offset=123_1&handle=${h1}`, ENV, "batch_1", CURRENT_API_VERSION, undefined, "1.0.0" ); expect(res3.headers.get("electric-handle")).toBe(h1); }); }); describe("NativeRealtimeClient resolve admission gate (mass-reconnect stampede)", () => { // A resolver that blocks each invocation until released, so we can watch how many run // concurrently. Tracks peak concurrency and exposes a release-one-at-a-time drain. function gatedResolver() { let active = 0; let peak = 0; const releases: Array<() => void> = []; const resolve = vi.fn(async () => { active++; peak = Math.max(peak, active); await new Promise((r) => releases.push(r)); active--; return ["run_1"]; }); return { resolve, peak: () => peak, releaseOne: () => releases.shift()?.(), waiting: () => releases.length, }; } function makeGatedClient( resolveAdmissionLimit: number, resolver: ReturnType ) { const hydrateSpy = vi.fn(async (_env: string, ids: string[]) => ids.map(row)); return new NativeRealtimeClient({ runReader: { getRunById: async () => null, hydrateByIds: hydrateSpy } as any, runListResolver: { resolveMatchingRunIds: resolver.resolve } as any, router: new EnvChangeRouter({ source: { subscribeToEnv: () => () => {} }, hydrator: { hydrateByIds: hydrateSpy }, replayWindowMs: 0, unsubscribeLingerMs: 0, }), limiter: { incrementAndCheck: async () => true, decrement: async () => {} } as any, cachedLimitProvider: { getCachedLimit: async () => 100 }, maximumCreatedAtFilterAgeMs: 24 * 60 * 60 * 1000, runSetResolveCacheTtlMs: 0, // no cache -> every distinct filter is a fresh resolve resolveAdmissionLimit, }); } it("throttles a distinct-filter stampede to the admission limit of concurrent CH resolves", async () => { const resolver = gatedResolver(); const client = makeGatedClient(2, resolver); // 5 distinct batchIds => 5 distinct filters => 5 fresh resolves, fired at once. const polls = [0, 1, 2, 3, 4].map((i) => snapshot(client, `batch_${i}`)); // Only the limit (2) may run concurrently; the rest queue for a permit. await vi.waitFor(() => expect(resolver.resolve).toHaveBeenCalledTimes(2)); await sleep(20); expect(resolver.resolve).toHaveBeenCalledTimes(2); // 3 still queued behind the gate expect(resolver.peak()).toBe(2); // Drain: each release frees a permit, admitting exactly one queued resolve. while (resolver.waiting() > 0) { resolver.releaseOne(); await sleep(5); } await Promise.all(polls); expect(resolver.resolve).toHaveBeenCalledTimes(5); // all ran... expect(resolver.peak()).toBe(2); // ...but never more than the limit at once }); it("lets a same-filter burst through on a single permit (coalesces before the gate)", async () => { const resolver = gatedResolver(); const client = makeGatedClient(1, resolver); // limit 1 would deadlock if each took a permit // 5 identical filters fired at once -> single-flight collapses to one in-flight resolve. const polls = [0, 1, 2, 3, 4].map(() => snapshot(client, "batch_same")); await vi.waitFor(() => expect(resolver.resolve).toHaveBeenCalledTimes(1)); await sleep(20); resolver.releaseOne(); await Promise.all(polls); expect(resolver.resolve).toHaveBeenCalledTimes(1); // one resolve, one permit, no queue }); }); describe("NativeRealtimeClient tag-list createdAt bucketing", () => { it("floors the resolved createdAt lower bound to the bucket boundary", async () => { // Fix the clock to a non-bucket-aligned instant so the assertion is deterministic. vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-06-07T10:00:30.500Z")); try { const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 60_000 }); await snapshotTag(client, ["critical"]); const passed = resolveSpy.mock.calls[0][0].createdAtAfter as Date; expect(passed.getTime() % 60_000).toBe(0); } finally { vi.useRealTimers(); } }); it("lets two same-tag feeds in the same bucket share one resolve", async () => { // A large bucket guarantees both windows floor to the same boundary regardless of // the sub-millisecond gap between the two calls. const { client, resolveSpy, hydrateSpy } = makeClient({ runSetCreatedAtBucketMs: 60 * 60_000, }); await snapshotTag(client, ["critical"]); await snapshotTag(client, ["critical"]); expect(resolveSpy).toHaveBeenCalledTimes(1); expect(hydrateSpy).toHaveBeenCalledTimes(1); }); it("does not share across different tags", async () => { const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 60 * 60_000 }); await snapshotTag(client, ["critical"]); await snapshotTag(client, ["debug"]); expect(resolveSpy).toHaveBeenCalledTimes(2); }); it("does not collide a comma-containing tag with two separate tags", async () => { const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 60 * 60_000 }); await snapshotTag(client, ["a,b"]); // one tag "a,b" await snapshotTag(client, ["a", "b"]); // two tags a OR b — a different filter expect(resolveSpy).toHaveBeenCalledTimes(2); }); it("keeps each feed's exact lower bound when bucketing is disabled (0)", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-06-07T10:00:30.500Z")); try { const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 0 }); await snapshotTag(client, ["critical"]); const passed = resolveSpy.mock.calls[0][0].createdAtAfter as Date; // Exact (now - 24h) lower bound, not floored to a 60s boundary. expect(passed.getTime() % 60_000).not.toBe(0); } finally { vi.useRealTimers(); } }); }); describe("NativeRealtimeClient review fixes", () => { // makeClient's router has a no-op source, so the live poll never gets a wake and falls // through to its backstop timeout — the full ClickHouse resolve these tests assert on // (createdAt clamp / concurrency limit). it("clamps a stale/crafted handle's createdAt up to the max-age floor", async () => { const maxAge = 24 * 60 * 60 * 1000; const { client, resolveSpy } = makeClient({ maximumCreatedAtFilterAgeMs: maxAge, runSetCreatedAtBucketMs: 0, livePollTimeoutMs: 50, }); const before = Date.now(); // Handle encodes createdAt = 1ms epoch, far older than the 24h ceiling. await client.streamRuns( "http://localhost:3030/realtime/v1/runs?offset=123_1&live=true&handle=runs_1_7", ENV, { tags: ["t"] }, CURRENT_API_VERSION, undefined, "1.0.0" ); const passed = resolveSpy.mock.calls[0][0].createdAtAfter as Date; // Clamped to ~now - maxAge, not the epoch value encoded in the handle. expect(passed.getTime()).toBeGreaterThan(before - maxAge - 1_000); }); it("enforces a concurrency limit of 0 instead of failing with a 500", async () => { let limitCheckedWith: number | undefined; const { client } = makeClient({ cachedLimitProvider: { getCachedLimit: async () => 0 }, limiter: { incrementAndCheck: async (_env: string, _id: string, limit: number) => { limitCheckedWith = limit; return true; }, decrement: async () => {}, }, livePollTimeoutMs: 50, }); const res = await client.streamBatch( "http://localhost:3030/realtime/v1/batches/batch_1?offset=123_1&live=true&handle=batch_batch_1_7_abc", ENV, "batch_1", CURRENT_API_VERSION, undefined, "1.0.0" ); expect(res.status).toBe(200); expect(limitCheckedWith).toBe(0); }); });