@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
package/dist/scheduler.js CHANGED
@@ -11,8 +11,9 @@ export function getSchedulerInfo() {
11
11
  failedJobs: _failedJobs,
12
12
  };
13
13
  }
14
- export function createScheduler() {
14
+ export function createScheduler(options) {
15
15
  const timers = new Map();
16
+ const isDurable = !!(options?.persistJob && options?.removeJob);
16
17
  const cronJobs = new Map();
17
18
  const actions = new Map();
18
19
  let jobCounter = 0;
@@ -42,51 +43,75 @@ export function createScheduler() {
42
43
  await handler(args);
43
44
  console.log(`[scheduler] Action "${action}" completed`);
44
45
  }
46
+ /** @internal 인메모리 setTimeout 기반 스케줄링 (로컬 dev + durable fallback) */
47
+ function scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry) {
48
+ const timer = setTimeout(async () => {
49
+ jobEntry.status = "running";
50
+ try {
51
+ await executeAction(action, args);
52
+ jobEntry.status = "completed";
53
+ }
54
+ catch (error) {
55
+ const err = error instanceof Error ? error : new Error(String(error));
56
+ jobEntry.status = "failed";
57
+ console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
58
+ // 실패 기록 보관 (dead-letter)
59
+ recordFailure(id, action, args, err);
60
+ // onError 콜백 실행 — 다른 action에 에러 정보 전달
61
+ if (scheduleOpts?.onError) {
62
+ try {
63
+ await executeAction(scheduleOpts.onError, {
64
+ failedAction: action,
65
+ failedJobId: id,
66
+ error: err.message,
67
+ originalArgs: args,
68
+ });
69
+ console.log(`[scheduler] onError handler "${scheduleOpts.onError}" completed for "${action}"`);
70
+ }
71
+ catch (onErrorErr) {
72
+ console.error(`[scheduler] onError handler "${scheduleOpts.onError}" also failed:`, onErrorErr);
73
+ }
74
+ }
75
+ }
76
+ finally {
77
+ timers.delete(id);
78
+ // completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
79
+ setTimeout(() => {
80
+ const idx = _pendingJobs.findIndex((j) => j.id === id);
81
+ if (idx >= 0)
82
+ _pendingJobs.splice(idx, 1);
83
+ }, 60_000); // 1분 후 정리
84
+ }
85
+ }, ms);
86
+ timers.set(id, timer);
87
+ console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`);
88
+ }
45
89
  return {
46
- runAfter(ms, action, args, options) {
90
+ runAfter(ms, action, args, scheduleOpts) {
47
91
  const id = generateId();
48
92
  const jobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
49
93
  _pendingJobs.push(jobEntry);
50
- const timer = setTimeout(async () => {
51
- jobEntry.status = "running";
52
- try {
53
- await executeAction(action, args);
54
- jobEntry.status = "completed";
55
- }
56
- catch (error) {
57
- const err = error instanceof Error ? error : new Error(String(error));
58
- jobEntry.status = "failed";
59
- console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
60
- // 실패 기록 보관 (dead-letter)
61
- recordFailure(id, action, args, err);
62
- // onError 콜백 실행 — 다른 action에 에러 정보 전달
63
- if (options?.onError) {
64
- try {
65
- await executeAction(options.onError, {
66
- failedAction: action,
67
- failedJobId: id,
68
- error: err.message,
69
- originalArgs: args,
70
- });
71
- console.log(`[scheduler] onError handler "${options.onError}" completed for "${action}"`);
72
- }
73
- catch (onErrorErr) {
74
- console.error(`[scheduler] onError handler "${options.onError}" also failed:`, onErrorErr);
75
- }
76
- }
77
- }
78
- finally {
79
- timers.delete(id);
80
- // completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
81
- setTimeout(() => {
82
- const idx = _pendingJobs.findIndex((j) => j.id === id);
83
- if (idx >= 0)
84
- _pendingJobs.splice(idx, 1);
85
- }, 60_000); // 1분 후 정리
86
- }
87
- }, ms);
88
- timers.set(id, timer);
89
- console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${options?.onError ? ` [onError: ${options.onError}]` : ""}`);
94
+ // ── Durable mode: DB에 영속화, 실행은 외부 폴러에 위임 ──
95
+ if (isDurable) {
96
+ const runAt = new Date(Date.now() + ms);
97
+ options.persistJob({
98
+ id,
99
+ action,
100
+ args: args ?? {},
101
+ runAt,
102
+ onErrorAction: scheduleOpts?.onError,
103
+ }).then(() => {
104
+ console.log(`[scheduler] Persisted "${action}" to run at ${runAt.toISOString()} (id: ${id}, durable)` +
105
+ `${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`);
106
+ }).catch((err) => {
107
+ console.error(`[scheduler] Failed to persist job ${id}:`, err instanceof Error ? err.message : err);
108
+ // DB persist 실패 → 인메모리 fallback
109
+ scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
110
+ });
111
+ return id;
112
+ }
113
+ // ── 인메모리 mode: 기존 setTimeout 동작 ──
114
+ scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
90
115
  return id;
91
116
  },
92
117
  runAt(timestamp, action, args, options) {
@@ -95,6 +120,22 @@ export function createScheduler() {
95
120
  return this.runAfter(ms, action, args, options);
96
121
  },
97
122
  cancel(jobId) {
123
+ // ── Durable mode: DB에서도 제거 ──
124
+ if (isDurable) {
125
+ const idx = _pendingJobs.findIndex((j) => j.id === jobId);
126
+ if (idx >= 0) {
127
+ _pendingJobs.splice(idx, 1);
128
+ options.removeJob(jobId).catch((err) => {
129
+ console.error(`[scheduler] Failed to remove persisted job ${jobId}:`, err instanceof Error ? err.message : err);
130
+ });
131
+ console.log(`[scheduler] Cancelled job ${jobId} (durable)`);
132
+ return true;
133
+ }
134
+ // pendingJobs에 없어도 DB에는 있을 수 있으므로 삭제 시도
135
+ options.removeJob(jobId).catch(() => { });
136
+ return false;
137
+ }
138
+ // ── 인메모리 mode ──
98
139
  const timer = timers.get(jobId);
99
140
  if (timer) {
100
141
  clearTimeout(timer);
package/dist/server.d.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * executing server. Excluded from client-side core (`index.ts`) so they aren't
6
6
  * bundled into user functions which run in Firecracker.
7
7
  */
8
- export { createDb } from "./db";
9
- export { createStorage, storageRoutes } from "./storage";
10
- export type { StorageImageTierConfig } from "./storage";
11
- export { createScheduler, getSchedulerInfo } from "./scheduler";
12
- export { authMiddleware, authRoutes, getUsers } from "./auth";
8
+ export { createDb } from "./db.js";
9
+ export { createStorage, storageRoutes } from "./storage.js";
10
+ export type { StorageImageTierConfig } from "./storage.js";
11
+ export { createScheduler, getSchedulerInfo } from "./scheduler.js";
12
+ export { authMiddleware, authRoutes, getUsers } from "./auth.js";
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * executing server. Excluded from client-side core (`index.ts`) so they aren't
6
6
  * bundled into user functions which run in Firecracker.
7
7
  */
8
- export { createDb } from "./db";
9
- export { createStorage, storageRoutes } from "./storage";
10
- export { createScheduler, getSchedulerInfo } from "./scheduler";
11
- export { authMiddleware, authRoutes, getUsers } from "./auth";
8
+ export { createDb } from "./db.js";
9
+ export { createStorage, storageRoutes } from "./storage.js";
10
+ export { createScheduler, getSchedulerInfo } from "./scheduler.js";
11
+ export { authMiddleware, authRoutes, getUsers } from "./auth.js";
package/package.json CHANGED
@@ -1,45 +1,46 @@
1
1
  {
2
- "name": "@gencow/core",
3
- "version": "0.1.21",
4
- "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "import": "./dist/index.js",
11
- "require": "./dist/index.js",
12
- "types": "./dist/index.d.ts"
2
+ "name": "@gencow/core",
3
+ "version": "0.1.23",
4
+ "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./server": {
15
+ "import": "./dist/server.js",
16
+ "require": "./dist/server.js",
17
+ "types": "./dist/server.d.ts"
18
+ }
13
19
  },
14
- "./server": {
15
- "import": "./dist/server.js",
16
- "require": "./dist/server.js",
17
- "types": "./dist/server.d.ts"
20
+ "files": [
21
+ "dist/",
22
+ "src/"
23
+ ],
24
+ "scripts": {
25
+ "db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
26
+ "build": "tsc",
27
+ "typecheck": "tsc --noEmit",
28
+ "prepublishOnly": "npm run build",
29
+ "postinstall": "tsc"
30
+ },
31
+ "dependencies": {
32
+ "@electric-sql/pglite": "^0.3.15",
33
+ "drizzle-orm": "^0.45.1",
34
+ "hono": "^4.12.0",
35
+ "node-cron": "^4.2.1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/bun": "^1.3.9",
39
+ "@types/node": "^25.3.0",
40
+ "@types/node-cron": "^3.0.11",
41
+ "drizzle-kit": "^0.31.10",
42
+ "drizzle-seed": "^0.3.1",
43
+ "typescript": "^5.9.3",
44
+ "uuid": "^13.0.0"
18
45
  }
19
- },
20
- "files": [
21
- "dist/",
22
- "src/"
23
- ],
24
- "dependencies": {
25
- "@electric-sql/pglite": "^0.3.15",
26
- "drizzle-orm": "^0.45.1",
27
- "hono": "^4.12.0",
28
- "node-cron": "^4.2.1"
29
- },
30
- "devDependencies": {
31
- "@types/bun": "^1.3.9",
32
- "@types/node": "^25.3.0",
33
- "@types/node-cron": "^3.0.11",
34
- "drizzle-kit": "^0.31.10",
35
- "drizzle-seed": "^0.3.1",
36
- "typescript": "^5.9.3",
37
- "uuid": "^13.0.0"
38
- },
39
- "scripts": {
40
- "db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
41
- "build": "tsc",
42
- "typecheck": "tsc --noEmit",
43
- "postinstall": "tsc"
44
- }
45
- }
46
+ }
@@ -236,17 +236,17 @@ describe("crud() + ownerRls — 데이터 격리", () => {
236
236
  expect(values.userId).toBe("user-A");
237
237
  });
238
238
 
239
- it("create: 사용자가 임의의 userId를 주입 시도해도 강제 덮어씀 (보안)", async () => {
239
+ it("create: 타인 user_id 주입 시도는 거부되고 insert까지 가지 않음 (보안)", async () => {
240
240
  const mutations = getRegisteredMutations();
241
241
  const createDef = mutations.find((m: any) => m.name === "rls_tasks.create");
242
242
 
243
243
  const { ctx, getCapturedValues } = createMockCtx("user-A");
244
- // 해커가 user_id를 "hacker-id"로 조작 시도
245
- await createDef!.handler(ctx, { title: "Spoofed", user_id: "hacker-id" });
244
+ // 해커가 user_id를 "hacker-id"로 조작 시도 — Layer 1은 즉시 Forbidden (덮어쓰기 전 차단)
245
+ await expect(
246
+ createDef!.handler(ctx, { title: "Spoofed", user_id: "hacker-id" }),
247
+ ).rejects.toThrow("Forbidden: cannot create resource for another user");
246
248
 
247
- const values = getCapturedValues();
248
- // 인증된 사용자 ID로 강제 덮어씀 (JS 프로퍼티명)
249
- expect(values.userId).toBe("user-A");
249
+ expect(getCapturedValues()).toBeNull();
250
250
  });
251
251
 
252
252
  // ── update 격리 ──
@@ -1,3 +1,11 @@
1
+ CREATE TABLE "news" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "title" text NOT NULL,
4
+ "user_id" text NOT NULL,
5
+ "created_at" timestamp DEFAULT now() NOT NULL,
6
+ "updated_at" timestamp DEFAULT now() NOT NULL
7
+ );
8
+ --> statement-breakpoint
1
9
  CREATE TABLE "tasks" (
2
10
  "id" text PRIMARY KEY NOT NULL,
3
11
  "title" text NOT NULL,
@@ -57,6 +65,7 @@ CREATE TABLE "verification" (
57
65
  "updated_at" timestamp DEFAULT now()
58
66
  );
59
67
  --> statement-breakpoint
68
+ ALTER TABLE "news" ADD CONSTRAINT "news_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
60
69
  ALTER TABLE "tasks" ADD CONSTRAINT "tasks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
61
70
  ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
62
71
  ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -1,9 +1,68 @@
1
1
  {
2
- "id": "5dae5382-aa29-4251-9a52-0df4786c5100",
2
+ "id": "d57dfdaa-8d90-493e-834f-580b33548adc",
3
3
  "prevId": "00000000-0000-0000-0000-000000000000",
4
4
  "version": "7",
5
5
  "dialect": "postgresql",
6
6
  "tables": {
7
+ "public.news": {
8
+ "name": "news",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "title": {
18
+ "name": "title",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "user_id": {
24
+ "name": "user_id",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "created_at": {
30
+ "name": "created_at",
31
+ "type": "timestamp",
32
+ "primaryKey": false,
33
+ "notNull": true,
34
+ "default": "now()"
35
+ },
36
+ "updated_at": {
37
+ "name": "updated_at",
38
+ "type": "timestamp",
39
+ "primaryKey": false,
40
+ "notNull": true,
41
+ "default": "now()"
42
+ }
43
+ },
44
+ "indexes": {},
45
+ "foreignKeys": {
46
+ "news_user_id_user_id_fk": {
47
+ "name": "news_user_id_user_id_fk",
48
+ "tableFrom": "news",
49
+ "tableTo": "user",
50
+ "columnsFrom": [
51
+ "user_id"
52
+ ],
53
+ "columnsTo": [
54
+ "id"
55
+ ],
56
+ "onDelete": "cascade",
57
+ "onUpdate": "no action"
58
+ }
59
+ },
60
+ "compositePrimaryKeys": {},
61
+ "uniqueConstraints": {},
62
+ "policies": {},
63
+ "checkConstraints": {},
64
+ "isRLSEnabled": false
65
+ },
7
66
  "public.tasks": {
8
67
  "name": "tasks",
9
68
  "schema": "",
@@ -5,8 +5,8 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "7",
8
- "when": 1776143474320,
9
- "tag": "0000_faithful_silver_sable",
8
+ "when": 1776398037133,
9
+ "tag": "0000_last_warstar",
10
10
  "breakpoints": true
11
11
  }
12
12
  ]
@@ -7,13 +7,13 @@
7
7
  *
8
8
  * 변경 후: gencow dev가 자동 반영
9
9
  */
10
- import { ownerRls } from "../../../rls";
10
+ import { ownerRls } from "../../../rls.js";
11
11
  import { pgTable } from "drizzle-orm/pg-core";
12
12
  import { text, boolean, timestamp } from "drizzle-orm/pg-core";
13
- import { user } from "../common/auth-schema";
13
+ import { user } from "../common/auth-schema.js";
14
14
  import { v4 as uuidv4 } from "uuid";
15
15
 
16
- export { user } from "../common/auth-schema";
16
+ export { user } from "../common/auth-schema.js";
17
17
 
18
18
  export const tasks = pgTable(
19
19
  "tasks",
@@ -33,3 +33,19 @@ export const tasks = pgTable(
33
33
  },
34
34
  (t) => ownerRls(t.userId)
35
35
  );
36
+
37
+ /**
38
+ * Public-ish content table **without** `ownerRls()` — no PostgreSQL RLS policies; used for
39
+ * `crud(..., { public: true })` + no-DB-RLS integration tests.
40
+ */
41
+ export const news = pgTable("news", {
42
+ id: text("id")
43
+ .primaryKey()
44
+ .$defaultFn(() => uuidv4()),
45
+ title: text("title").notNull(),
46
+ userId: text("user_id")
47
+ .notNull()
48
+ .references(() => user.id, { onDelete: "cascade" }),
49
+ createdAt: timestamp("created_at").defaultNow().notNull(),
50
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
51
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared PGlite + `fixtures/basic` schema seed + RLS app role for integration tests.
3
+ */
4
+ import { dirname, join } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import type { InferSelectModel } from "drizzle-orm";
7
+ import { getTableName, sql } from "drizzle-orm";
8
+ import type { PgTable } from "drizzle-orm/pg-core";
9
+ import { PGlite } from "@electric-sql/pglite";
10
+ import { drizzle } from "drizzle-orm/pglite";
11
+
12
+ import type { UserIdentity } from "../../reactive.js";
13
+ import { news, tasks, user } from "../fixtures/basic/schema.js";
14
+ import { createPgliteRlsAppRole, DEFAULT_PGLITE_RLS_APP_ROLE, setPgliteSessionRole } from "./pglite-rls-session.js";
15
+ import { loadAndApplyMigrations } from "./pglite-migrations.js";
16
+ import { fillPartialRowsForInsert } from "./seed-like-fill.js";
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
20
+ export const basicFixtureUsers = [
21
+ { id: "us_000", name: "User 0", email: "user-0@s.com", emailVerified: true },
22
+ { id: "us_001", name: "User 1", email: "user-1@s.com", emailVerified: true },
23
+ ];
24
+
25
+ /** Stable ids/titles; overlaps across users for search / RLS tests. */
26
+ export const basicFixtureTasks = [
27
+ {
28
+ id: "tk-000",
29
+ userId: basicFixtureUsers[0].id,
30
+ done: false,
31
+ title: "Project Alpha — Q4 review prep",
32
+ },
33
+ {
34
+ id: "tk-001",
35
+ userId: basicFixtureUsers[1].id,
36
+ done: true,
37
+ title: "Project Alpha — teammate handoff",
38
+ },
39
+ {
40
+ id: "tk-002",
41
+ userId: basicFixtureUsers[0].id,
42
+ done: false,
43
+ title: "Project Alpha — backlog grooming",
44
+ },
45
+ {
46
+ id: "tk-003",
47
+ userId: basicFixtureUsers[0].id,
48
+ done: false,
49
+ title: "Quarterly planning — Q4",
50
+ },
51
+ {
52
+ id: "tk-004",
53
+ userId: basicFixtureUsers[1].id,
54
+ done: false,
55
+ title: "Project Beta — API docs",
56
+ },
57
+ {
58
+ id: "tk-005",
59
+ userId: basicFixtureUsers[1].id,
60
+ done: false,
61
+ title: "Project Gamma — research notes",
62
+ },
63
+ {
64
+ id: "tk-006",
65
+ userId: basicFixtureUsers[0].id,
66
+ done: false,
67
+ title: "Project Beta — spike",
68
+ },
69
+ ];
70
+
71
+ /** No `ownerRls()` — two rows for user0 / user1 (no DB RLS on `news`). */
72
+ export const basicFixtureNews = [
73
+ { id: "nw-000", userId: basicFixtureUsers[0].id, title: "owned by user0" },
74
+ { id: "nw-001", userId: basicFixtureUsers[1].id, title: "owned by user1" },
75
+ ];
76
+
77
+ export const basicUser0Identity = {
78
+ id: basicFixtureUsers[0].id,
79
+ email: basicFixtureUsers[0].email,
80
+ } satisfies UserIdentity;
81
+
82
+ export const basicUser1Identity = {
83
+ id: basicFixtureUsers[1].id,
84
+ email: basicFixtureUsers[1].email,
85
+ } satisfies UserIdentity;
86
+
87
+ export type BasicTaskRow = InferSelectModel<typeof tasks>;
88
+ export type BasicNewsRow = InferSelectModel<typeof news>;
89
+
90
+ /**
91
+ * Bootstrap user migrations, seed users/tasks as table owner, then `SET ROLE` to non-owner app role
92
+ * so PostgreSQL RLS policies apply (same as `rls-crud-basic.test.ts`).
93
+ */
94
+ export async function createBasicRlsEnvironment(): Promise<{
95
+ client: PGlite;
96
+ db: ReturnType<typeof drizzle>;
97
+ taskRows: BasicTaskRow[];
98
+ }> {
99
+ const client = new PGlite();
100
+ await client.waitReady;
101
+ await loadAndApplyMigrations(client, join(__dirname, "../fixtures/basic/migrations"));
102
+ const db = drizzle(client);
103
+ await db.insert(user).values(fillPartialRowsForInsert(user, basicFixtureUsers));
104
+ const taskRows = fillPartialRowsForInsert(tasks, basicFixtureTasks) as BasicTaskRow[];
105
+ await db.insert(tasks).values(taskRows);
106
+ const newsRows = fillPartialRowsForInsert(news, basicFixtureNews) as BasicNewsRow[];
107
+ await db.insert(news).values(newsRows);
108
+ await createPgliteRlsAppRole(client, {
109
+ roleName: DEFAULT_PGLITE_RLS_APP_ROLE,
110
+ });
111
+ await setPgliteSessionRole(client, DEFAULT_PGLITE_RLS_APP_ROLE);
112
+ return { client, db, taskRows };
113
+ }
114
+
115
+ /** Asserts `pg_class.relrowsecurity` is false (table was never `ENABLE ROW LEVEL SECURITY`). */
116
+ export async function assertTableRowLevelSecurityDisabled(
117
+ db: ReturnType<typeof drizzle>,
118
+ table: PgTable,
119
+ ): Promise<void> {
120
+ const relname = getTableName(table);
121
+ const q = await db.execute(sql`
122
+ select c.relrowsecurity as rls_enabled
123
+ from pg_class c
124
+ join pg_namespace n on n.oid = c.relnamespace
125
+ where n.nspname = 'public' and c.relname = ${relname}
126
+ `);
127
+ const row = (q as unknown as { rows: { rls_enabled: boolean }[] }).rows[0];
128
+ if (row?.rls_enabled !== false) {
129
+ throw new Error(
130
+ `expected relrowsecurity = false for public.${relname}, got ${JSON.stringify(row)}`,
131
+ );
132
+ }
133
+ }
@@ -7,7 +7,7 @@ export function makeTestGencowCtxWithRls(
7
7
  db: ReturnType<typeof drizzle>,
8
8
  identity: UserIdentity
9
9
  ): GencowCtx {
10
- const scoped = createRlsDb(db, identity.id);
10
+ const scoped = createRlsDb(db, { userId: identity.id });
11
11
  return {
12
12
  db: scoped,
13
13
  unsafeDb: db,