@gencow/core 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/crud.d.ts +12 -12
  2. package/dist/crud.js +4 -4
  3. package/dist/index.d.ts +19 -18
  4. package/dist/index.js +10 -10
  5. package/dist/reactive.d.ts +4 -4
  6. package/dist/reactive.js +6 -0
  7. package/dist/rls-db.d.ts +43 -4
  8. package/dist/rls-db.js +212 -7
  9. package/dist/rls.d.ts +1 -1
  10. package/dist/rls.js +1 -1
  11. package/dist/scheduler.d.ts +35 -5
  12. package/dist/scheduler.js +83 -42
  13. package/dist/server.d.ts +5 -5
  14. package/dist/server.js +4 -4
  15. package/package.json +43 -42
  16. package/src/__tests__/crud-owner-rls.test.ts +6 -6
  17. package/src/__tests__/fixtures/basic/migrations/{0000_faithful_silver_sable.sql → 0000_last_warstar.sql} +9 -0
  18. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +60 -1
  19. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +2 -2
  20. package/src/__tests__/fixtures/basic/schema.ts +19 -3
  21. package/src/__tests__/helpers/basic-rls-fixture.ts +133 -0
  22. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +1 -1
  23. package/src/__tests__/reactive.test.ts +161 -0
  24. package/src/__tests__/rls-crud-basic.test.ts +120 -161
  25. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +117 -0
  26. package/src/__tests__/rls-custom-mutation-handlers.test.ts +189 -0
  27. package/src/__tests__/rls-custom-query-handlers.test.ts +128 -0
  28. package/src/__tests__/rls-db-leased-connection.test.ts +122 -0
  29. package/src/__tests__/rls-session-and-policies.test.ts +246 -0
  30. package/src/__tests__/scheduler-durable-v2.test.ts +270 -0
  31. package/src/__tests__/scheduler-durable.test.ts +173 -0
  32. package/src/crud.ts +4 -4
  33. package/src/index.ts +19 -18
  34. package/src/reactive.ts +12 -4
  35. package/src/rls-db.ts +277 -10
  36. package/src/rls.ts +1 -1
  37. package/src/scheduler.ts +124 -46
  38. package/src/server.ts +5 -5
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Custom **mutation** handlers (not `crud()` factory) that use `ctx.db` must respect RLS
3
+ * (update/delete/insert withCheck).
4
+ *
5
+ * Run: bun test packages/core/src/__tests__/rls-custom-mutation-handlers.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
9
+ import { eq } from "drizzle-orm";
10
+ import { PGlite } from "@electric-sql/pglite";
11
+ import { drizzle } from "drizzle-orm/pglite";
12
+
13
+ import type { GencowCtx } from "../reactive.js";
14
+ import { tasks } from "./fixtures/basic/schema.js";
15
+ import {
16
+ basicFixtureUsers,
17
+ basicUser0Identity,
18
+ basicUser1Identity,
19
+ createBasicRlsEnvironment,
20
+ } from "./helpers/basic-rls-fixture.js";
21
+ import { fillPartialRowsForInsert } from "./helpers/seed-like-fill.js";
22
+ import {
23
+ makeTestGencowCtxWithRls,
24
+ runWithRollbackTestGencowCtxWithRls,
25
+ } from "./helpers/test-gencow-ctx-rls.js";
26
+
27
+ /** User-defined mutation: update title by task id. */
28
+ async function customMutationUpdateTitle(
29
+ ctx: GencowCtx,
30
+ taskId: string,
31
+ title: string
32
+ ) {
33
+ return ctx.db
34
+ .update(tasks)
35
+ .set({ title, updatedAt: new Date() })
36
+ .where(eq(tasks.id, taskId))
37
+ .returning();
38
+ }
39
+
40
+ /** User-defined mutation: delete by id. */
41
+ async function customMutationDeleteById(ctx: GencowCtx, taskId: string) {
42
+ return ctx.db.delete(tasks).where(eq(tasks.id, taskId)).returning();
43
+ }
44
+
45
+ /** User-defined mutation: insert a new task for the current user (caller supplies logical fields). */
46
+ async function customMutationInsertTask(
47
+ ctx: GencowCtx,
48
+ values: { id: string; title: string; userId: string; done?: boolean }
49
+ ) {
50
+ const [row] = fillPartialRowsForInsert(tasks, [
51
+ {
52
+ id: values.id,
53
+ title: values.title,
54
+ userId: values.userId,
55
+ done: values.done ?? false,
56
+ },
57
+ ]);
58
+ return ctx.db.insert(tasks).values(row).returning();
59
+ }
60
+
61
+ describe("Custom mutation handlers + ctx.db + RLS", () => {
62
+ let client: PGlite;
63
+ let db: ReturnType<typeof drizzle>;
64
+
65
+ beforeAll(async () => {
66
+ const env = await createBasicRlsEnvironment();
67
+ client = env.client;
68
+ db = env.db;
69
+ });
70
+
71
+ afterAll(async () => {
72
+ try {
73
+ await client.close();
74
+ } catch {
75
+ /* ignore */
76
+ }
77
+ });
78
+
79
+ it("custom update: can change own task", async () => {
80
+ await runWithRollbackTestGencowCtxWithRls(
81
+ db,
82
+ basicUser0Identity,
83
+ async (ctx) => {
84
+ const updated = await customMutationUpdateTitle(
85
+ ctx,
86
+ "tk-003",
87
+ "Updated by custom handler"
88
+ );
89
+ expect(updated.length).toBe(1);
90
+ expect(updated[0]!.title).toBe("Updated by custom handler");
91
+ }
92
+ );
93
+ });
94
+
95
+ it("custom update: cannot modify another user's task (0 rows)", async () => {
96
+ await runWithRollbackTestGencowCtxWithRls(
97
+ db,
98
+ basicUser0Identity,
99
+ async (ctx) => {
100
+ const updated = await customMutationUpdateTitle(
101
+ ctx,
102
+ "tk-001",
103
+ "Should not apply"
104
+ );
105
+ expect(updated.length).toBe(0);
106
+ }
107
+ );
108
+ });
109
+
110
+ it("custom delete: can remove own task", async () => {
111
+ await runWithRollbackTestGencowCtxWithRls(
112
+ db,
113
+ basicUser0Identity,
114
+ async (ctx) => {
115
+ const removed = await customMutationDeleteById(ctx, "tk-000");
116
+ expect(removed.length).toBe(1);
117
+ }
118
+ );
119
+ });
120
+
121
+ it("custom delete: cannot remove another user's task (0 rows)", async () => {
122
+ await runWithRollbackTestGencowCtxWithRls(
123
+ db,
124
+ basicUser0Identity,
125
+ async (ctx) => {
126
+ const removed = await customMutationDeleteById(ctx, "tk-004");
127
+ expect(removed.length).toBe(0);
128
+ }
129
+ );
130
+ });
131
+
132
+ it("custom insert: succeeds when userId matches session", async () => {
133
+ await runWithRollbackTestGencowCtxWithRls(
134
+ db,
135
+ basicUser0Identity,
136
+ async (ctx) => {
137
+ const inserted = await customMutationInsertTask(ctx, {
138
+ id: "tk-custom-own",
139
+ title: "Custom insert OK",
140
+ userId: basicUser0Identity.id,
141
+ });
142
+ expect(inserted.length).toBe(1);
143
+ expect(inserted[0]!.userId).toBe(basicUser0Identity.id);
144
+ }
145
+ );
146
+ });
147
+
148
+ it("custom insert: forged userId (another user) violates RLS withCheck", async () => {
149
+ await runWithRollbackTestGencowCtxWithRls(
150
+ db,
151
+ basicUser0Identity,
152
+ async (ctx) => {
153
+ let thrown: unknown;
154
+ try {
155
+ await customMutationInsertTask(ctx, {
156
+ id: "tk-custom-forge",
157
+ title: "Forged owner",
158
+ userId: basicFixtureUsers[1].id,
159
+ });
160
+ } catch (e) {
161
+ thrown = e;
162
+ }
163
+ expect(thrown).toBeInstanceOf(Error);
164
+ }
165
+ );
166
+ });
167
+
168
+ it("nested db.transaction in custom handler still applies RLS on inner selects", async () => {
169
+ await runWithRollbackTestGencowCtxWithRls(
170
+ db,
171
+ basicUser0Identity,
172
+ async (ctx) => {
173
+ const inner = await ctx.db.transaction(async (tx: typeof ctx.db) => {
174
+ return tx
175
+ .select()
176
+ .from(tasks)
177
+ .where(eq(tasks.userId, basicFixtureUsers[1].id));
178
+ });
179
+ expect(inner.length).toBe(0);
180
+ }
181
+ );
182
+ });
183
+
184
+ it("direct ctx.db without outer rollback wrapper: mutation still RLS-scoped", async () => {
185
+ const ctx = makeTestGencowCtxWithRls(db, basicUser1Identity);
186
+ const updated = await customMutationUpdateTitle(ctx, "tk-000", "hax");
187
+ expect(updated.length).toBe(0);
188
+ });
189
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Custom **query** handlers (not `crud()` factory) that use `ctx.db` with Drizzle must respect RLS.
3
+ *
4
+ * Run: bun test packages/core/src/__tests__/rls-custom-query-handlers.test.ts
5
+ */
6
+
7
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
8
+ import { eq, sql } from "drizzle-orm";
9
+ import { PGlite } from "@electric-sql/pglite";
10
+ import { drizzle } from "drizzle-orm/pglite";
11
+
12
+ import type { GencowCtx } from "../reactive.js";
13
+ import { tasks } from "./fixtures/basic/schema.js";
14
+ import type { BasicTaskRow } from "./helpers/basic-rls-fixture.js";
15
+ import {
16
+ basicFixtureTasks,
17
+ basicFixtureUsers,
18
+ basicUser0Identity,
19
+ basicUser1Identity,
20
+ createBasicRlsEnvironment,
21
+ } from "./helpers/basic-rls-fixture.js";
22
+ import { makeTestGencowCtxWithRls } from "./helpers/test-gencow-ctx-rls.js";
23
+
24
+ /** Simulates a user-defined query: list all tasks the ORM can see (RLS limits rows). */
25
+ async function customQueryListAllTasks(ctx: GencowCtx) {
26
+ return ctx.db.select().from(tasks);
27
+ }
28
+
29
+ /** Simulates a dashboard query with filters — still subject to RLS. */
30
+ async function customQueryTasksMatchingTitle(ctx: GencowCtx, substring: string) {
31
+ return ctx.db
32
+ .select()
33
+ .from(tasks)
34
+ .where(sql`${tasks.title} ilike ${"%" + substring + "%"}`);
35
+ }
36
+
37
+ /** Simulates fetching a single row by id (e.g. detail view). */
38
+ async function customQueryTaskById(ctx: GencowCtx, id: string) {
39
+ const rows = await ctx.db.select().from(tasks).where(eq(tasks.id, id));
40
+ return rows[0] ?? null;
41
+ }
42
+
43
+ /** Raw SQL count — must run with RLS session vars on the same connection semantics as ORM builders. */
44
+ async function customQueryTaskCountRaw(ctx: GencowCtx) {
45
+ const r = await ctx.db.execute(sql`select count(*)::int as c from ${tasks}`);
46
+ const rows = r.rows as { c: number }[];
47
+ return rows[0]?.c ?? 0;
48
+ }
49
+
50
+ describe("Custom query handlers + ctx.db + RLS", () => {
51
+ let client: PGlite;
52
+ let db: ReturnType<typeof drizzle>;
53
+
54
+ beforeAll(async () => {
55
+ const env = await createBasicRlsEnvironment();
56
+ client = env.client;
57
+ db = env.db;
58
+ });
59
+
60
+ afterAll(async () => {
61
+ try {
62
+ await client.close();
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ });
67
+
68
+ it("custom list: user0 only sees own rows (not user1 tasks)", async () => {
69
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
70
+ const rows = await customQueryListAllTasks(ctx);
71
+ expect(rows.every((r: BasicTaskRow) => r.userId === basicUser0Identity.id)).toBe(true);
72
+ const expectedOwn = basicFixtureTasks.filter((t) => t.userId === basicFixtureUsers[0].id).length;
73
+ expect(rows.length).toBe(expectedOwn);
74
+ });
75
+
76
+ it("custom list: user1 sees a different row set than user0", async () => {
77
+ const ctx0 = makeTestGencowCtxWithRls(db, basicUser0Identity);
78
+ const ctx1 = makeTestGencowCtxWithRls(db, basicUser1Identity);
79
+ const u0 = await customQueryListAllTasks(ctx0);
80
+ const u1 = await customQueryListAllTasks(ctx1);
81
+ const u0Ids = new Set(u0.map((r: BasicTaskRow) => r.id));
82
+ const u1Ids = new Set(u1.map((r: BasicTaskRow) => r.id));
83
+ expect(u0Ids.has("tk-001")).toBe(false);
84
+ expect(u1Ids.has("tk-001")).toBe(true);
85
+ expect(u1Ids.has("tk-000")).toBe(false);
86
+ });
87
+
88
+ it("custom filtered query: cannot see other user's rows even if title matches", async () => {
89
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
90
+ const rows = await customQueryTasksMatchingTitle(ctx, "Project Alpha");
91
+ expect(rows.every((r: BasicTaskRow) => r.userId === basicUser0Identity.id)).toBe(true);
92
+ expect(rows.some((r: BasicTaskRow) => r.id === "tk-001")).toBe(false);
93
+ });
94
+
95
+ it("custom get by id: returns null for another user's task id", async () => {
96
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
97
+ const row = await customQueryTaskById(ctx, "tk-001");
98
+ expect(row).toBeNull();
99
+ });
100
+
101
+ it("custom get by id: returns own task", async () => {
102
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
103
+ const row = await customQueryTaskById(ctx, "tk-003");
104
+ expect(row).not.toBeNull();
105
+ expect(row!.id).toBe("tk-003");
106
+ });
107
+
108
+ it("raw SQL count on ctx.db matches RLS-visible rows only", async () => {
109
+ const ctx0 = makeTestGencowCtxWithRls(db, basicUser0Identity);
110
+ const ctx1 = makeTestGencowCtxWithRls(db, basicUser1Identity);
111
+ const c0 = await customQueryTaskCountRaw(ctx0);
112
+ const c1 = await customQueryTaskCountRaw(ctx1);
113
+ const n0 = basicFixtureTasks.filter((t) => t.userId === basicFixtureUsers[0].id).length;
114
+ const n1 = basicFixtureTasks.filter((t) => t.userId === basicFixtureUsers[1].id).length;
115
+ expect(c0).toBe(n0);
116
+ expect(c1).toBe(n1);
117
+ expect(c0 + c1).toBe(basicFixtureTasks.length);
118
+ });
119
+
120
+ it("unsafeDb without createRlsDb: no session GUC — owner RLS yields no visible rows", async () => {
121
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
122
+ const rlsRows = await customQueryListAllTasks(ctx);
123
+ expect(rlsRows.length).toBeGreaterThan(0);
124
+
125
+ const raw = await ctx.unsafeDb.select().from(tasks);
126
+ expect(raw.length).toBe(0);
127
+ });
128
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { withRlsLeasedConnection } from "../rls-db.js";
3
+
4
+ /**
5
+ * Mirrors production **node-pg Pool** path (`rls-db.ts` path 4): `connect()` → BEGIN →
6
+ * `set_config` for each RLS field → user work → COMMIT / ROLLBACK → `release()`.
7
+ * Real pools hit PostgreSQL; these tests assert ordering and side effects on a mock client.
8
+ */
9
+
10
+ describe("withRlsLeasedConnection (pg Pool leased client path)", () => {
11
+ it("commits on success and releases exactly once", async () => {
12
+ const phases: string[] = [];
13
+ let released = 0;
14
+ const leased = {
15
+ async query(sql: string) {
16
+ phases.push(sql);
17
+ return {};
18
+ },
19
+ release() {
20
+ released++;
21
+ },
22
+ };
23
+ const result = await withRlsLeasedConnection(
24
+ leased,
25
+ { userId: "u1" },
26
+ async () => "done"
27
+ );
28
+ expect(result).toBe("done");
29
+ expect(phases[0]).toBe("begin");
30
+ expect(phases[phases.length - 1]).toBe("commit");
31
+ expect(phases).not.toContain("rollback");
32
+ expect(released).toBe(1);
33
+ });
34
+
35
+ it("rolls back when work fails and never commits", async () => {
36
+ const phases: string[] = [];
37
+ let released = 0;
38
+ const leased = {
39
+ async query(sql: string) {
40
+ phases.push(sql);
41
+ return {};
42
+ },
43
+ release() {
44
+ released++;
45
+ },
46
+ };
47
+ let thrown: unknown;
48
+ try {
49
+ await withRlsLeasedConnection(leased, { userId: "u1" }, async () => {
50
+ throw new Error("work failed");
51
+ });
52
+ } catch (e) {
53
+ thrown = e;
54
+ }
55
+ expect(thrown).toBeInstanceOf(Error);
56
+ expect((thrown as Error).message).toBe("work failed");
57
+
58
+ expect(phases[0]).toBe("begin");
59
+ expect(phases[phases.length - 1]).toBe("rollback");
60
+ expect(phases.includes("commit")).toBe(false);
61
+ expect(released).toBe(1);
62
+ });
63
+
64
+ it("issues set_config for userId, role, tenantId, and custom vars before commit", async () => {
65
+ const phases: string[] = [];
66
+ let released = 0;
67
+ const leased = {
68
+ async query(q: string) {
69
+ phases.push(q);
70
+ return {};
71
+ },
72
+ release() {
73
+ released++;
74
+ },
75
+ };
76
+ await withRlsLeasedConnection(
77
+ leased,
78
+ {
79
+ userId: "u1",
80
+ role: "admin",
81
+ tenantId: "t1",
82
+ vars: { "app.org_slug": "acme" },
83
+ },
84
+ async () => "ok"
85
+ );
86
+ expect(phases[0]).toBe("begin");
87
+ const setCalls = phases.filter((p) => p.includes("set_config"));
88
+ expect(setCalls.length).toBe(4);
89
+ expect(phases[phases.length - 1]).toBe("commit");
90
+ expect(released).toBe(1);
91
+ });
92
+
93
+ it("still rolls back and releases when commit fails after successful work", async () => {
94
+ const phases: string[] = [];
95
+ let released = 0;
96
+ let commitCalls = 0;
97
+ const leased = {
98
+ async query(q: string) {
99
+ phases.push(q);
100
+ if (q === "commit") {
101
+ commitCalls++;
102
+ throw new Error("commit failed");
103
+ }
104
+ return {};
105
+ },
106
+ release() {
107
+ released++;
108
+ },
109
+ };
110
+ let thrown: unknown;
111
+ try {
112
+ await withRlsLeasedConnection(leased, { userId: "u1" }, async () => "done");
113
+ } catch (e) {
114
+ thrown = e;
115
+ }
116
+ expect(thrown).toBeInstanceOf(Error);
117
+ expect((thrown as Error).message).toBe("commit failed");
118
+ expect(commitCalls).toBe(1);
119
+ expect(phases[phases.length - 1]).toBe("rollback");
120
+ expect(released).toBe(1);
121
+ });
122
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * RLS session GUCs + PostgreSQL policies — PGlite integration tests.
3
+ *
4
+ * **Parity with production (`packages/server/src/index.ts`):**
5
+ * - Server builds `ctx.db` with `createRlsDb(db, { userId: authCtx?.getUserIdentity()?.id || "" })`.
6
+ * - Tests use the same helper shape via {@link makeTestGencowCtxWithRls} (only `userId` from identity),
7
+ * or `createRlsDb` directly with `role` / `tenantId` / `vars` when exercising those code paths.
8
+ *
9
+ * **Parity with PostgreSQL RLS enforcement:**
10
+ * - After {@link createPgliteRlsAppRole} + {@link setPgliteSessionRole}, the session runs as a
11
+ * non-table-owner role, so `ENABLE ROW LEVEL SECURITY` policies apply (same as production DB users).
12
+ * - Policies in `fixtures/basic/migrations` use `current_setting('app.current_user_id', true)` with
13
+ * `missing_ok`, matching `ownerRls()` in `rls.ts` and avoiding errors before `set_config`.
14
+ *
15
+ * **Execution paths exercised:**
16
+ * - **Autocommit / PGlite `transaction()`**: `makeTestGencowCtxWithRls(db)` — each statement opens a
17
+ * short transaction with session GUCs (see `rls-db.ts` path 3).
18
+ * - **Reuse outer Drizzle transaction**: `runWithRollbackTestGencowCtxWithRls` — matches handlers that
19
+ * run inside an outer `db.transaction()`; `createRlsDb` detects the tx object and applies GUCs on
20
+ * the same connection without a nested top-level `BEGIN` (path 1).
21
+ *
22
+ * Run: bun test packages/core/src/__tests__/rls-session-and-policies.test.ts
23
+ */
24
+
25
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
26
+ import { eq, sql } from "drizzle-orm";
27
+
28
+ import { createRlsDb } from "../rls-db.js";
29
+ import { tasks } from "./fixtures/basic/schema.js";
30
+ import {
31
+ basicFixtureTasks,
32
+ basicUser0Identity,
33
+ basicUser1Identity,
34
+ createBasicRlsEnvironment,
35
+ } from "./helpers/basic-rls-fixture.js";
36
+ import { fillPartialRowsForInsert } from "./helpers/seed-like-fill.js";
37
+ import {
38
+ makeTestGencowCtxWithRls,
39
+ runWithRollbackTestGencowCtxWithRls,
40
+ } from "./helpers/test-gencow-ctx-rls.js";
41
+
42
+ type PgliteDb = ReturnType<typeof import("drizzle-orm/pglite").drizzle>;
43
+
44
+ describe("RLS session + policies (PGlite, non-owner session)", () => {
45
+ let client: import("@electric-sql/pglite").PGlite;
46
+ let db: PgliteDb;
47
+
48
+ beforeAll(async () => {
49
+ const env = await createBasicRlsEnvironment();
50
+ client = env.client;
51
+ db = env.db;
52
+ });
53
+
54
+ afterAll(async () => {
55
+ try {
56
+ await client.close();
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ });
61
+
62
+ function expectedCountForUser(userId: string): number {
63
+ return basicFixtureTasks.filter((t) => t.userId === userId).length;
64
+ }
65
+
66
+ it("empty userId matches unauthenticated server contract — no rows visible", async () => {
67
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
68
+ userId: "",
69
+ });
70
+ const rows = await scoped.select().from(tasks);
71
+ expect(rows.length).toBe(0);
72
+ });
73
+
74
+ it("authenticated userId sees only own rows (policy USING)", async () => {
75
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
76
+ userId: basicUser0Identity.id,
77
+ });
78
+ const rows = await scoped.select().from(tasks);
79
+ expect(rows.length).toBe(expectedCountForUser(basicUser0Identity.id));
80
+ expect(rows.every((r) => r.userId === basicUser0Identity.id)).toBe(true);
81
+ });
82
+
83
+ it("createRlsDb sets optional role and tenantId GUCs (readable via execute)", async () => {
84
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
85
+ userId: basicUser0Identity.id,
86
+ role: "editor",
87
+ tenantId: "tenant_test",
88
+ });
89
+ const r = await scoped.execute(
90
+ sql`select current_setting('app.current_user_role', true) as role,
91
+ current_setting('app.tenant_id', true) as tid`,
92
+ );
93
+ const row = (r as { rows: { role: string; tid: string }[] }).rows[0];
94
+ expect(row?.role).toBe("editor");
95
+ expect(row?.tid).toBe("tenant_test");
96
+ });
97
+
98
+ it("custom vars (validated app.* names) are applied for set_config", async () => {
99
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
100
+ userId: basicUser0Identity.id,
101
+ vars: { "app.org_slug": "acme" },
102
+ });
103
+ const r = await scoped.execute(
104
+ sql`select current_setting('app.org_slug', true) as slug`,
105
+ );
106
+ const row = (r as { rows: { slug: string }[] }).rows[0];
107
+ expect(row?.slug).toBe("acme");
108
+ });
109
+
110
+ it("invalid GUC name in vars throws before executing SQL", async () => {
111
+ const bad = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
112
+ userId: basicUser0Identity.id,
113
+ vars: { "App.invalid": "x" },
114
+ });
115
+ await expect(Promise.resolve(bad.select().from(tasks))).rejects.toThrow(
116
+ /invalid/,
117
+ );
118
+ });
119
+
120
+ it("reserved keys must not appear in vars", async () => {
121
+ const bad = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
122
+ userId: basicUser0Identity.id,
123
+ vars: { "app.current_user_id": "hijack" },
124
+ });
125
+ await expect(Promise.resolve(bad.select().from(tasks))).rejects.toThrow(
126
+ /vars must not set/,
127
+ );
128
+ });
129
+
130
+ it("autocommit path and outer-transaction path return the same RLS-visible counts", async () => {
131
+ const expected = expectedCountForUser(basicUser0Identity.id);
132
+
133
+ const ctxPlain = makeTestGencowCtxWithRls(db, basicUser0Identity);
134
+ const plainCount = (await ctxPlain.db.select().from(tasks)).length;
135
+ expect(plainCount).toBe(expected);
136
+
137
+ await runWithRollbackTestGencowCtxWithRls(
138
+ db,
139
+ basicUser0Identity,
140
+ async (ctx) => {
141
+ const txCount = (await ctx.db.select().from(tasks)).length;
142
+ expect(txCount).toBe(expected);
143
+ },
144
+ );
145
+ });
146
+
147
+ it("createRlsDb(db).transaction() injects session vars — inner selects respect RLS", async () => {
148
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
149
+ userId: basicUser0Identity.id,
150
+ });
151
+ await scoped.transaction(async (tx) => {
152
+ const rows = await tx.select().from(tasks);
153
+ expect(rows.length).toBe(expectedCountForUser(basicUser0Identity.id));
154
+ });
155
+ });
156
+
157
+ it("parallel selects on the same ctx.db both see RLS-consistent results", async () => {
158
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
159
+ const expected = expectedCountForUser(basicUser0Identity.id);
160
+ const [a, b] = await Promise.all([
161
+ ctx.db.select().from(tasks),
162
+ ctx.db.select().from(tasks),
163
+ ]);
164
+ expect(a.length).toBe(expected);
165
+ expect(b.length).toBe(expected);
166
+ });
167
+
168
+ it("unsafeDb INSERT without session GUC fails RLS withCheck (non-owner)", async () => {
169
+ const [row] = fillPartialRowsForInsert(tasks, [
170
+ {
171
+ id: "tk-rls-unsafe-insert",
172
+ title: "should not persist",
173
+ userId: basicUser0Identity.id,
174
+ done: false,
175
+ },
176
+ ]);
177
+ await expect(Promise.resolve(db.insert(tasks).values(row))).rejects.toThrow();
178
+ });
179
+
180
+ it("unsafeDb UPDATE touches 0 rows when session GUC is unset", async () => {
181
+ const updated = await db
182
+ .update(tasks)
183
+ .set({ title: "nope", updatedAt: new Date() })
184
+ .where(eq(tasks.id, "tk-000"))
185
+ .returning();
186
+ expect(updated.length).toBe(0);
187
+ });
188
+
189
+ it("scoped INSERT cannot forge another user row (withCheck)", async () => {
190
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
191
+ userId: basicUser0Identity.id,
192
+ });
193
+ const [row] = fillPartialRowsForInsert(tasks, [
194
+ {
195
+ id: "tk-forge-other",
196
+ title: "forged",
197
+ userId: basicUser1Identity.id,
198
+ done: false,
199
+ },
200
+ ]);
201
+ await expect(Promise.resolve(scoped.insert(tasks).values(row))).rejects.toThrow();
202
+ });
203
+
204
+ it("wrong scoped userId cannot read other user's row by primary key", async () => {
205
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
206
+ userId: basicUser0Identity.id,
207
+ });
208
+ const rows = await scoped
209
+ .select()
210
+ .from(tasks)
211
+ .where(eq(tasks.id, "tk-001"));
212
+ expect(rows.length).toBe(0);
213
+ });
214
+
215
+ it("scoped UPDATE affects 0 rows for another user's task (policy USING)", async () => {
216
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
217
+ userId: basicUser0Identity.id,
218
+ });
219
+ const updated = await scoped
220
+ .update(tasks)
221
+ .set({ title: "blocked", updatedAt: new Date() })
222
+ .where(eq(tasks.id, "tk-001"))
223
+ .returning();
224
+ expect(updated.length).toBe(0);
225
+ });
226
+
227
+ it("scoped DELETE removes 0 rows for another user's task (policy USING)", async () => {
228
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
229
+ userId: basicUser0Identity.id,
230
+ });
231
+ const removed = await scoped.delete(tasks).where(eq(tasks.id, "tk-001")).returning();
232
+ expect(removed.length).toBe(0);
233
+ });
234
+
235
+ it("nested raw SQL count matches builder select count for same session", async () => {
236
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
237
+ userId: basicUser0Identity.id,
238
+ });
239
+ const fromBuilder = await scoped.select().from(tasks);
240
+ const raw = await scoped.execute(
241
+ sql`select count(*)::int as c from ${tasks}`,
242
+ );
243
+ const c = (raw as { rows: { c: number }[] }).rows[0]?.c ?? -1;
244
+ expect(c).toBe(fromBuilder.length);
245
+ });
246
+ });