@gencow/core 0.1.22 → 0.1.24

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 (44) hide show
  1. package/dist/crud.js +1 -1
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.js +3 -0
  4. package/dist/reactive.js +6 -0
  5. package/dist/rls-db.d.ts +43 -4
  6. package/dist/rls-db.js +212 -7
  7. package/dist/rls.d.ts +1 -1
  8. package/dist/rls.js +1 -1
  9. package/dist/scheduler.d.ts +35 -5
  10. package/dist/scheduler.js +83 -42
  11. package/dist/workflow-types.d.ts +81 -0
  12. package/dist/workflow-types.js +12 -0
  13. package/dist/workflow.d.ts +30 -0
  14. package/dist/workflow.js +157 -0
  15. package/dist/workflows-api.d.ts +13 -0
  16. package/dist/workflows-api.js +328 -0
  17. package/package.json +1 -1
  18. package/src/__tests__/crud-owner-rls.test.ts +6 -6
  19. package/src/__tests__/dist-exports.test.ts +6 -0
  20. package/src/__tests__/fixtures/basic/migrations/{0000_faithful_silver_sable.sql → 0000_last_warstar.sql} +9 -0
  21. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +60 -1
  22. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +2 -2
  23. package/src/__tests__/fixtures/basic/schema.ts +19 -3
  24. package/src/__tests__/helpers/basic-rls-fixture.ts +133 -0
  25. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +1 -1
  26. package/src/__tests__/reactive.test.ts +161 -0
  27. package/src/__tests__/rls-crud-basic.test.ts +120 -161
  28. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +117 -0
  29. package/src/__tests__/rls-custom-mutation-handlers.test.ts +189 -0
  30. package/src/__tests__/rls-custom-query-handlers.test.ts +128 -0
  31. package/src/__tests__/rls-db-leased-connection.test.ts +122 -0
  32. package/src/__tests__/rls-session-and-policies.test.ts +246 -0
  33. package/src/__tests__/scheduler-durable-v2.test.ts +270 -0
  34. package/src/__tests__/scheduler-durable.test.ts +173 -0
  35. package/src/__tests__/workflow.test.ts +583 -0
  36. package/src/crud.ts +1 -1
  37. package/src/index.ts +6 -4
  38. package/src/reactive.ts +8 -0
  39. package/src/rls-db.ts +277 -10
  40. package/src/rls.ts +1 -1
  41. package/src/scheduler.ts +124 -46
  42. package/src/workflow-types.ts +111 -0
  43. package/src/workflow.ts +205 -0
  44. package/src/workflows-api.ts +425 -0
@@ -5,131 +5,39 @@
5
5
  * filled by drizzle-seed’s same per-type generators as `seed()` (see `fillPartialRowsForInsert`).
6
6
  *
7
7
  * Migrations run as the PGlite bootstrap user (table owner). We then create `gencow_rls_app` and
8
- * `SET ROLE` so the session is non-owner → RLS policies apply. `createRlsDb` + crud’s use of
9
- * `db.transaction` sets `app.current_user_id` per operation.
8
+ * `SET ROLE` so the session is non-owner → RLS policies apply. `createRlsDb` injects
9
+ * `app.current_user_id` for every query path (including bare `select()` / `execute()`).
10
10
  * We rely on `current_setting('app.current_user_id', true)` (missing_ok=true): this avoids
11
11
  * missing-GUC errors before `set_config` and keeps PGlite behavior aligned with PostgreSQL.
12
12
  *
13
13
  * Run: bun test packages/core/src/__tests__/rls-crud-basic.test.ts
14
14
  */
15
15
 
16
- import {
17
- describe,
18
- it,
19
- expect,
20
- beforeAll,
21
- afterAll,
22
- } from "bun:test";
23
- import { dirname, join } from "path";
24
- import { fileURLToPath } from "url";
25
- import type { InferSelectModel } from "drizzle-orm";
16
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
17
+ import { eq, type InferSelectModel } from "drizzle-orm";
26
18
  import { PGlite } from "@electric-sql/pglite";
27
19
  import { drizzle } from "drizzle-orm/pglite";
28
- import { crud } from "../crud";
29
- import type { UserIdentity } from "../reactive";
30
- import { tasks, user } from "./fixtures/basic/schema";
20
+ import { crud } from "../crud.js";
21
+ import { createRlsDb } from "../rls-db.js";
22
+ import { tasks } from "./fixtures/basic/schema.js";
31
23
  import {
32
- createPgliteRlsAppRole,
33
- DEFAULT_PGLITE_RLS_APP_ROLE,
34
- setPgliteSessionRole,
35
- } from "./helpers/pglite-rls-session";
36
- import { loadAndApplyMigrations } from "./helpers/pglite-migrations";
37
- import { fillPartialRowsForInsert } from "./helpers/seed-like-fill";
24
+ basicFixtureUsers as fixtureUsers,
25
+ basicFixtureTasks as fixtureTasks,
26
+ basicUser0Identity as user0Identity,
27
+ basicUser1Identity as user1Identity,
28
+ createBasicRlsEnvironment,
29
+ } from "./helpers/basic-rls-fixture.js";
38
30
  import {
39
31
  makeTestGencowCtxWithRls,
40
32
  runWithRollbackTestGencowCtxWithRls,
41
- } from "./helpers/test-gencow-ctx-rls";
42
-
43
- const __dirname = dirname(fileURLToPath(import.meta.url));
44
-
45
- const fixtureUsers = [
46
- { id: "us_000", name: "User 0", email: "user-0@s.com", emailVerified: true },
47
- { id: "us_001", name: "User 1", email: "user-1@s.com", emailVerified: true },
48
- ];
49
-
50
- /** Realistic titles; "Project Alpha" appears on two rows for us_000 and one for us_001 (RLS). */
51
- const fixtureTasks = [
52
- {
53
- id: "tk-000",
54
- userId: fixtureUsers[0].id,
55
- done: false,
56
- title: "Project Alpha — Q4 review prep",
57
- },
58
- {
59
- id: "tk-001",
60
- userId: fixtureUsers[1].id,
61
- done: true,
62
- title: "Project Alpha — teammate handoff",
63
- },
64
- {
65
- id: "tk-002",
66
- userId: fixtureUsers[0].id,
67
- done: false,
68
- title: "Project Alpha — backlog grooming",
69
- },
70
- {
71
- id: "tk-003",
72
- userId: fixtureUsers[0].id,
73
- done: false,
74
- title: "Quarterly planning — Q4",
75
- },
76
- {
77
- id: "tk-004",
78
- userId: fixtureUsers[1].id,
79
- done: false,
80
- title: "Project Beta — API docs",
81
- },
82
- {
83
- id: "tk-005",
84
- userId: fixtureUsers[1].id,
85
- done: false,
86
- title: "Project Gamma — research notes",
87
- },
88
- {
89
- id: "tk-006",
90
- userId: fixtureUsers[0].id,
91
- done: false,
92
- title: "Project Beta — spike",
93
- },
94
- ];
95
-
96
- const user0Identity = {
97
- id: fixtureUsers[0].id,
98
- email: fixtureUsers[0].email,
99
- } satisfies UserIdentity;
100
-
101
- const user1Identity = {
102
- id: fixtureUsers[1].id,
103
- email: fixtureUsers[1].email,
104
- } satisfies UserIdentity;
33
+ } from "./helpers/test-gencow-ctx-rls.js";
105
34
 
106
35
  type TaskRow = InferSelectModel<typeof tasks>;
107
36
  type CrudDefs = ReturnType<typeof crud<typeof tasks>>;
108
37
  type TaskListResult = { data: TaskRow[]; total: number };
109
38
 
110
- async function seedBasicFixtures(db: ReturnType<typeof drizzle>) {
111
- await db.insert(user).values(fillPartialRowsForInsert(user, fixtureUsers));
112
- const taskRows = fillPartialRowsForInsert(
113
- tasks,
114
- fixtureTasks
115
- ) as TaskRow[];
116
- await db.insert(tasks).values(taskRows);
117
- return taskRows;
118
- }
119
-
120
39
  async function createSeededCrudEnv() {
121
- const client = new PGlite();
122
- await client.waitReady;
123
- await loadAndApplyMigrations(
124
- client,
125
- join(__dirname, "fixtures/basic/migrations")
126
- );
127
- const db = drizzle(client);
128
- const taskRows = await seedBasicFixtures(db);
129
- await createPgliteRlsAppRole(client, {
130
- roleName: DEFAULT_PGLITE_RLS_APP_ROLE,
131
- });
132
- await setPgliteSessionRole(client, DEFAULT_PGLITE_RLS_APP_ROLE);
40
+ const { client, db, taskRows } = await createBasicRlsEnvironment();
133
41
 
134
42
  const defs = crud(tasks, {
135
43
  prefix: "fixture_basic_pglite_tasks",
@@ -187,78 +95,116 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
187
95
  }
188
96
  });
189
97
 
98
+ it("createRlsDb: bare select() applies RLS without explicit db.transaction", async () => {
99
+ const scoped = createRlsDb(db as any, {
100
+ userId: user0Identity.id,
101
+ role: "user",
102
+ tenantId: "tenant_fixture",
103
+ vars: { "app.note": "ok" },
104
+ });
105
+ const otherUserRows = await scoped
106
+ .select()
107
+ .from(tasks)
108
+ .where(eq(tasks.userId, fixtureUsers[1].id));
109
+ expect(otherUserRows.length).toBe(0);
110
+
111
+ const ownRows = await scoped
112
+ .select()
113
+ .from(tasks)
114
+ .where(eq(tasks.userId, user0Identity.id));
115
+ expect(ownRows.length).toBe(
116
+ seededTaskRows.filter((r) => r.userId === user0Identity.id).length
117
+ );
118
+ });
119
+
190
120
  it("tasks.list returns only the current user (us_000) rows per RLS and total matches", async () => {
191
121
  expect(listDef).toBeDefined();
192
122
 
193
- await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
194
- const listResult = await listHandler(ctx, {});
123
+ await runWithRollbackTestGencowCtxWithRls(
124
+ db,
125
+ user0Identity,
126
+ async (ctx) => {
127
+ const listResult = await listHandler(ctx, {});
195
128
 
196
- const expected = seededTaskRows.filter(
197
- (r) => r.userId === fixtureUsers[0].id
198
- );
129
+ const expected = seededTaskRows.filter(
130
+ (r) => r.userId === fixtureUsers[0].id
131
+ );
199
132
 
200
- expect(listResult).toHaveProperty("data");
201
- expect(listResult).toHaveProperty("total");
202
- expect(listResult.total).toBe(expected.length);
133
+ expect(listResult).toHaveProperty("data");
134
+ expect(listResult).toHaveProperty("total");
135
+ expect(listResult.total).toBe(expected.length);
203
136
 
204
- const rows = listResult.data;
137
+ const rows = listResult.data;
205
138
 
206
- const byId = new Map(rows.map((r) => [r.id, r]));
207
- expect(byId.size).toBe(expected.length);
139
+ const byId = new Map(rows.map((r) => [r.id, r]));
140
+ expect(byId.size).toBe(expected.length);
208
141
 
209
- for (const seeded of expected) {
210
- const row = byId.get(seeded.id);
211
- expect(row).toBeDefined();
212
- expect(row!.id).toBe(seeded.id);
213
- expect(row!.userId).toBe(seeded.userId);
214
- expect(row!.done).toBe(seeded.done);
215
- expect(row!.title).toBe(seeded.title);
142
+ for (const seeded of expected) {
143
+ const row = byId.get(seeded.id);
144
+ expect(row).toBeDefined();
145
+ expect(row!.id).toBe(seeded.id);
146
+ expect(row!.userId).toBe(seeded.userId);
147
+ expect(row!.done).toBe(seeded.done);
148
+ expect(row!.title).toBe(seeded.title);
149
+ }
216
150
  }
217
- });
151
+ );
218
152
  });
219
153
 
220
154
  it("search parameter applies to title/description ilike search", async () => {
221
155
  expect(listDef).toBeDefined();
222
156
 
223
- await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
224
- const sharedSubstring = "Project Alpha";
225
- const expectedForUser0 = seededTaskRows.filter(
226
- (r) =>
227
- r.userId === user0Identity.id &&
228
- r.title.toLowerCase().includes(sharedSubstring.toLowerCase())
229
- );
230
- expect(expectedForUser0.length).toBe(2);
231
-
232
- const result = await listHandler(ctx, { search: sharedSubstring });
233
- const rows = result.data;
234
- expect(rows.length).toBe(expectedForUser0.length);
235
-
236
- const byId = new Map(rows.map((r) => [r.id, r]));
237
- for (const seeded of expectedForUser0) {
238
- expect(byId.get(seeded.id)?.title).toBe(seeded.title);
157
+ await runWithRollbackTestGencowCtxWithRls(
158
+ db,
159
+ user0Identity,
160
+ async (ctx) => {
161
+ const sharedSubstring = "Project Alpha";
162
+ const expectedForUser0 = seededTaskRows.filter(
163
+ (r) =>
164
+ r.userId === user0Identity.id &&
165
+ r.title.toLowerCase().includes(sharedSubstring.toLowerCase())
166
+ );
167
+ expect(expectedForUser0.length).toBe(2);
168
+
169
+ const result = await listHandler(ctx, { search: sharedSubstring });
170
+ const rows = result.data;
171
+ expect(rows.length).toBe(expectedForUser0.length);
172
+
173
+ const byId = new Map(rows.map((r) => [r.id, r]));
174
+ for (const seeded of expectedForUser0) {
175
+ expect(byId.get(seeded.id)?.title).toBe(seeded.title);
176
+ }
239
177
  }
240
- });
178
+ );
241
179
  });
242
180
 
243
181
  it("tasks.get succeeds when current user reads own task", async () => {
244
182
  const ownTaskId = "tk-003";
245
183
 
246
- await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
247
- const task = await getHandler(ctx, { id: ownTaskId });
184
+ await runWithRollbackTestGencowCtxWithRls(
185
+ db,
186
+ user0Identity,
187
+ async (ctx) => {
188
+ const task = await getHandler(ctx, { id: ownTaskId });
248
189
 
249
- expect(task).toBeDefined();
250
- expect(task?.id).toBe(ownTaskId);
251
- expect(task?.userId).toBe(user0Identity.id);
252
- });
190
+ expect(task).toBeDefined();
191
+ expect(task?.id).toBe(ownTaskId);
192
+ expect(task?.userId).toBe(user0Identity.id);
193
+ }
194
+ );
253
195
  });
254
196
 
255
197
  it("tasks.get fails when current user tries to read another user's task", async () => {
256
198
  const user1TaskId = "tk-001";
257
199
 
258
- await runWithRollbackTestGencowCtxWithRls(db, user0Identity, async (ctx) => {
259
- const task = await getHandler(ctx, { id: user1TaskId });
260
- expect(task).toBeNull();
261
- });
200
+ await runWithRollbackTestGencowCtxWithRls(
201
+ db,
202
+ user0Identity,
203
+ async (ctx) => {
204
+ const task = await getHandler(ctx, { id: user1TaskId });
205
+ expect(task).toBeNull();
206
+ }
207
+ );
262
208
  });
263
209
 
264
210
  it("tasks.update succeeds when updating a task owned by current user", async () => {
@@ -335,16 +281,23 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
335
281
  db,
336
282
  user0Identity,
337
283
  async (user0Ctx) => {
338
- await expect(
339
- createHandler(user0Ctx, {
284
+ let thrown: unknown;
285
+ try {
286
+ await createHandler(user0Ctx, {
340
287
  id: unauthorizedTaskId,
341
288
  userId: user1Identity.id,
342
289
  title: "Unauthorized create attempt",
343
290
  done: false,
344
- })
345
- ).rejects.toThrow();
346
-
347
- const user1Ctx = makeTestGencowCtxWithRls(user0Ctx.unsafeDb as any, user1Identity);
291
+ });
292
+ } catch (e) {
293
+ thrown = e;
294
+ }
295
+ expect(thrown).toBeInstanceOf(Error);
296
+
297
+ const user1Ctx = makeTestGencowCtxWithRls(
298
+ user0Ctx.unsafeDb as any,
299
+ user1Identity
300
+ );
348
301
  const after = await getHandler(user1Ctx, { id: unauthorizedTaskId });
349
302
  expect(after).toBeNull();
350
303
  }
@@ -357,7 +310,10 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
357
310
  db,
358
311
  user0Identity,
359
312
  async (user0Ctx) => {
360
- const user1Ctx = makeTestGencowCtxWithRls(user0Ctx.unsafeDb as any, user1Identity);
313
+ const user1Ctx = makeTestGencowCtxWithRls(
314
+ user0Ctx.unsafeDb as any,
315
+ user1Identity
316
+ );
361
317
 
362
318
  const before = await getHandler(user1Ctx, {
363
319
  id: user1TaskId,
@@ -410,7 +366,10 @@ describe("fixtures/basic + PGlite + CRUD + RLS", () => {
410
366
  db,
411
367
  user0Identity,
412
368
  async (user0Ctx) => {
413
- const user1Ctx = makeTestGencowCtxWithRls(user0Ctx.unsafeDb as any, user1Identity);
369
+ const user1Ctx = makeTestGencowCtxWithRls(
370
+ user0Ctx.unsafeDb as any,
371
+ user1Identity
372
+ );
414
373
 
415
374
  const before = await getHandler(user1Ctx, { id: user1TaskId });
416
375
  expect(before).toBeDefined();
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `news` has **no** `ownerRls()` and **no** PostgreSQL RLS (`fixtures/basic/migrations/0001_news.sql`).
3
+ * `crud(news, { public: true })` keeps legacy behavior: no Layer-1 owner filter on list/get/update/remove.
4
+ *
5
+ * Complements mock-based `crud-owner-rls.test.ts` (“하위호환”) with PGlite + non-owner session.
6
+ *
7
+ * Run: bun test packages/core/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts
8
+ */
9
+
10
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
11
+
12
+ import { crud } from "../crud.js";
13
+ import { createRlsDb } from "../rls-db.js";
14
+ import { news } from "./fixtures/basic/schema.js";
15
+ import {
16
+ assertTableRowLevelSecurityDisabled,
17
+ basicUser0Identity,
18
+ basicUser1Identity,
19
+ createBasicRlsEnvironment,
20
+ } from "./helpers/basic-rls-fixture.js";
21
+ import {
22
+ makeTestGencowCtxWithRls,
23
+ runWithRollbackTestGencowCtxWithRls,
24
+ } from "./helpers/test-gencow-ctx-rls.js";
25
+
26
+ describe("crud + PGlite: news without ownerRls, public crud", () => {
27
+ let client: import("@electric-sql/pglite").PGlite;
28
+ let db: ReturnType<typeof import("drizzle-orm/pglite").drizzle>;
29
+ let listHandler: (ctx: unknown, args: unknown) => Promise<{ data: unknown[]; total: number }>;
30
+ let getHandler: (ctx: unknown, args: unknown) => Promise<unknown>;
31
+ let createHandler: (ctx: unknown, args: unknown) => Promise<unknown>;
32
+ let updateHandler: (ctx: unknown, args: unknown) => Promise<unknown>;
33
+ let removeHandler: (ctx: unknown, args: unknown) => Promise<{ success: boolean }>;
34
+
35
+ beforeAll(async () => {
36
+ const env = await createBasicRlsEnvironment();
37
+ client = env.client;
38
+ db = env.db;
39
+
40
+ const defs = crud(news, {
41
+ prefix: "fixture_basic_news",
42
+ public: true,
43
+ defaultLimit: 50,
44
+ });
45
+ listHandler = defs.list!.handler as (typeof listHandler);
46
+ getHandler = defs.get!.handler as (typeof getHandler);
47
+ createHandler = defs.create!.handler as (typeof createHandler);
48
+ updateHandler = defs.update!.handler as (typeof updateHandler);
49
+ removeHandler = defs.remove!.handler as (typeof removeHandler);
50
+ });
51
+
52
+ afterAll(async () => {
53
+ try {
54
+ await client.close();
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ });
59
+
60
+ it("list: authenticated user sees all rows (no owner filter)", async () => {
61
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
62
+ const result = await listHandler(ctx, {});
63
+ expect(result.total).toBe(2);
64
+ expect(result.data).toHaveLength(2);
65
+ const ids = new Set(result.data.map((r: any) => r.id));
66
+ expect(ids.has("nw-000")).toBe(true);
67
+ expect(ids.has("nw-001")).toBe(true);
68
+ });
69
+
70
+ it("get: can read another user row by id (no owner filter)", async () => {
71
+ const ctx = makeTestGencowCtxWithRls(db, basicUser0Identity);
72
+ const row = await getHandler(ctx, { id: "nw-001" });
73
+ expect(row).not.toBeNull();
74
+ expect((row as any).userId).toBe(basicUser1Identity.id);
75
+ });
76
+
77
+ it("createRlsDb + bare select: no DB policies — still full table visibility", async () => {
78
+ const scoped = createRlsDb(db as Parameters<typeof createRlsDb>[0], {
79
+ userId: basicUser0Identity.id,
80
+ });
81
+ const rows = await scoped.select().from(news);
82
+ expect(rows.length).toBe(2);
83
+ });
84
+
85
+ it("create: with public crud, pass userId explicitly (no auth-based auto-inject)", async () => {
86
+ await runWithRollbackTestGencowCtxWithRls(db, basicUser0Identity, async (ctx) => {
87
+ const created = await createHandler(ctx, {
88
+ id: "nw-created",
89
+ title: "new row",
90
+ userId: basicUser0Identity.id,
91
+ });
92
+ expect((created as any).userId).toBe(basicUser0Identity.id);
93
+ });
94
+ });
95
+
96
+ it("update: can change another user row by id (no owner WHERE)", async () => {
97
+ await runWithRollbackTestGencowCtxWithRls(db, basicUser0Identity, async (ctx) => {
98
+ const updated = await updateHandler(ctx, {
99
+ id: "nw-001",
100
+ title: "touched by user0",
101
+ });
102
+ expect((updated as any).title).toBe("touched by user0");
103
+ });
104
+ });
105
+
106
+ it("remove: can delete another user row by id (no owner WHERE)", async () => {
107
+ await runWithRollbackTestGencowCtxWithRls(db, basicUser0Identity, async (ctx) => {
108
+ await removeHandler(ctx, { id: "nw-001" });
109
+ const after = await getHandler(ctx, { id: "nw-001" });
110
+ expect(after).toBeNull();
111
+ });
112
+ });
113
+
114
+ it("pg_catalog: news has row security disabled", async () => {
115
+ await assertTableRowLevelSecurityDisabled(db, news);
116
+ });
117
+ });
@@ -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
+ });