|
| 1 | +import { mkdtempSync } from "node:fs"; |
| 2 | +import { tmpdir } from "node:os"; |
| 3 | +import { join } from "node:path"; |
| 4 | + |
| 5 | +import { Effect, Layer } from "effect"; |
| 6 | +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 7 | +import { withQueryContext } from "@executor-js/fumadb/query"; |
| 8 | + |
| 9 | +import { makeScopedExecutor } from "@executor-js/api/server"; |
| 10 | + |
| 11 | +import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; |
| 12 | +import { SelfHostScopedExecutorSeams } from "./execution"; |
| 13 | +import type { SelfHostPlugins } from "./plugins"; |
| 14 | + |
| 15 | +// The `subject` table is populated at the request seam: `makeScopedExecutor` is |
| 16 | +// what every HTTP request and MCP session on every host passes through, so |
| 17 | +// building a scoped executor IS a sighting. Exercised here against a real host's |
| 18 | +// seams (long-lived SQLite DbProvider + real plugins) rather than a stub, since |
| 19 | +// the point is that the production wiring reaches the writer at all. |
| 20 | + |
| 21 | +const createScopedExecutor = (accountId: string, organizationId: string) => |
| 22 | + makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe( |
| 23 | + Effect.provide(SelfHostScopedExecutorSeams), |
| 24 | + ); |
| 25 | + |
| 26 | +const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-")); |
| 27 | +process.env.EXECUTOR_DATA_DIR = dataDir; |
| 28 | + |
| 29 | +let dbLayer!: Layer.Layer<SelfHostDb>; |
| 30 | +let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined; |
| 31 | + |
| 32 | +beforeAll(async () => { |
| 33 | + dbHandle = await createSelfHostDb({ |
| 34 | + path: join(dataDir, "data.db"), |
| 35 | + namespace: "executor_selfhost", |
| 36 | + version: "1.0.0", |
| 37 | + }); |
| 38 | + dbLayer = Layer.succeed(SelfHostDb)(dbHandle); |
| 39 | +}); |
| 40 | +afterAll(async () => { |
| 41 | + await dbHandle?.close(); |
| 42 | +}); |
| 43 | + |
| 44 | +const subjectsOf = async (tenant: string): Promise<readonly string[]> => { |
| 45 | + const rows = await withQueryContext(dbHandle!.db, { |
| 46 | + tenant, |
| 47 | + subject: null, |
| 48 | + }).findMany("subject", { |
| 49 | + orderBy: ["external_id", "asc"], |
| 50 | + }); |
| 51 | + return rows.map((row) => String(row.external_id)); |
| 52 | +}; |
| 53 | + |
| 54 | +test("building a scoped executor records the acting principal", async () => { |
| 55 | + const before = Date.now(); |
| 56 | + await Effect.runPromise( |
| 57 | + createScopedExecutor("alice", "sightings-org").pipe(Effect.provide(dbLayer), Effect.asVoid), |
| 58 | + ); |
| 59 | + |
| 60 | + expect(await subjectsOf("sightings-org")).toEqual(["alice"]); |
| 61 | + |
| 62 | + const [row] = await withQueryContext(dbHandle!.db, { |
| 63 | + tenant: "sightings-org", |
| 64 | + subject: null, |
| 65 | + }).findMany("subject", {}); |
| 66 | + expect(Number(row?.last_seen_at)).toBeGreaterThanOrEqual(before); |
| 67 | +}); |
| 68 | + |
| 69 | +test("repeat requests from several members converge on one row each", async () => { |
| 70 | + const org = "sightings-org-2"; |
| 71 | + await Effect.runPromise( |
| 72 | + Effect.forEach( |
| 73 | + ["alice", "alice", "bob", "alice"], |
| 74 | + (accountId) => createScopedExecutor(accountId, org).pipe(Effect.asVoid), |
| 75 | + { discard: true }, |
| 76 | + ).pipe(Effect.provide(dbLayer)), |
| 77 | + ); |
| 78 | + |
| 79 | + // Four requests, two principals, two rows: the sighting is an upsert, and |
| 80 | + // the repeat sightings are absorbed by the throttle rather than duplicating. |
| 81 | + expect(await subjectsOf(org)).toEqual(["alice", "bob"]); |
| 82 | +}); |
0 commit comments