@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
@@ -294,3 +294,164 @@ describe("mutation(name, def) — query와 동일 패턴", () => {
294
294
  console.warn = originalWarn;
295
295
  });
296
296
  });
297
+
298
+ // ─── _flushRefresh — buildCtxForRefresh 통합 테스트 ──────────────────────────
299
+
300
+ describe("_flushRefresh() — query re-run via buildCtxForRefresh", () => {
301
+
302
+ it("buildCtxForRefresh 전달 시 query handler가 정상 ctx로 re-run된다", async () => {
303
+ // 테스트용 query 등록
304
+ const testQueryKey = "flush.test.rerun";
305
+ const handler = mock(async (ctx: any) => {
306
+ // ctx.db가 존재하는지 확인 (이전 버그: ctx = {} → crash)
307
+ if (!ctx.db) throw new Error("ctx.db is undefined!");
308
+ return [{ id: 1, count: ctx.db.mockValue }];
309
+ });
310
+
311
+ query(testQueryKey, { public: true, handler });
312
+
313
+ // buildCtxForRefresh 콜백 제공
314
+ const mockDb = { mockValue: 42 };
315
+ const rt = buildRealtimeCtx({
316
+ buildCtxForRefresh: () => ({
317
+ db: mockDb,
318
+ auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
319
+ realtime: { emit: () => {}, refresh: () => {} },
320
+ } as any),
321
+ });
322
+
323
+ rt.refresh(testQueryKey);
324
+ await rt._flushRefresh();
325
+
326
+ // handler가 호출됐는지 확인
327
+ expect(handler).toHaveBeenCalledTimes(1);
328
+ // ctx.db가 올바르게 전달됐는지 확인
329
+ const callCtx = handler.mock.calls[0][0];
330
+ expect(callCtx.db).toBe(mockDb);
331
+ });
332
+
333
+ it("buildCtxForRefresh 미전달 시 ({} as ctx) 사용 + 경고 로그 출력", async () => {
334
+ const testQueryKey = "flush.test.noCallback";
335
+ const handler = mock(async (_ctx: any) => [{ id: 1 }]);
336
+ query(testQueryKey, { public: true, handler });
337
+
338
+ const warnSpy = mock(() => {});
339
+ const originalWarn = console.warn;
340
+ console.warn = warnSpy;
341
+
342
+ // buildCtxForRefresh 없이 생성
343
+ const rt = buildRealtimeCtx();
344
+ rt.refresh(testQueryKey);
345
+ await rt._flushRefresh();
346
+
347
+ // 경고 출력 확인
348
+ const warnCalls = warnSpy.mock.calls.map(c => String(c[0]));
349
+ const hasWarning = warnCalls.some(msg =>
350
+ msg.includes("buildCtxForRefresh not provided")
351
+ );
352
+ expect(hasWarning).toBe(true);
353
+
354
+ console.warn = originalWarn;
355
+ });
356
+
357
+ it("refresh 결과가 WS 구독자에게 query:updated로 push된다", async () => {
358
+ const testQueryKey = "flush.test.push";
359
+ const freshData = [{ id: 99, name: "Refreshed" }];
360
+ query(testQueryKey, {
361
+ public: true,
362
+ handler: async () => freshData,
363
+ });
364
+
365
+ const ws = makeMockWs();
366
+ subscribe(testQueryKey, ws);
367
+
368
+ const rt = buildRealtimeCtx({
369
+ buildCtxForRefresh: () => ({
370
+ db: {},
371
+ auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
372
+ realtime: { emit: () => {}, refresh: () => {} },
373
+ } as any),
374
+ });
375
+
376
+ rt.refresh(testQueryKey);
377
+ await rt._flushRefresh();
378
+
379
+ // 50ms debounce 대기
380
+ await new Promise(r => setTimeout(r, 80));
381
+
382
+ expect(ws._sent.length).toBeGreaterThanOrEqual(1);
383
+ const msg = JSON.parse(ws._sent[ws._sent.length - 1]);
384
+ expect(msg.type).toBe("query:updated");
385
+ expect(msg.query).toBe(testQueryKey);
386
+ expect(msg.data).toEqual(freshData);
387
+
388
+ deregisterClient(ws);
389
+ });
390
+
391
+ it("httpCallback 모드에서 refresh 결과가 callback으로 전달된다", async () => {
392
+ const testQueryKey = "flush.test.http";
393
+ const freshData = [{ id: 77, title: "HTTP Push" }];
394
+ query(testQueryKey, {
395
+ public: true,
396
+ handler: async () => freshData,
397
+ });
398
+
399
+ const httpCallback = mock((_event: any) => {});
400
+
401
+ const rt = buildRealtimeCtx({
402
+ httpCallback,
403
+ buildCtxForRefresh: () => ({
404
+ db: {},
405
+ auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
406
+ realtime: { emit: () => {}, refresh: () => {} },
407
+ } as any),
408
+ });
409
+
410
+ rt.refresh(testQueryKey);
411
+ await rt._flushRefresh();
412
+
413
+ expect(httpCallback).toHaveBeenCalledTimes(1);
414
+ const event = httpCallback.mock.calls[0][0];
415
+ expect(event.type).toBe("emit");
416
+ expect(event.queryKey).toBe(testQueryKey);
417
+ expect(event.data).toEqual(freshData);
418
+ });
419
+
420
+ it("flush 후 _pendingRefresh가 비워진다", async () => {
421
+ const testQueryKey = "flush.test.clear";
422
+ query(testQueryKey, { public: true, handler: async () => [] });
423
+
424
+ const rt = buildRealtimeCtx({
425
+ buildCtxForRefresh: () => ({
426
+ db: {},
427
+ auth: { getUserIdentity: () => null, requireAuth: () => { throw new Error(); } },
428
+ realtime: { emit: () => {}, refresh: () => {} },
429
+ } as any),
430
+ });
431
+
432
+ rt.refresh(testQueryKey);
433
+ expect(rt._pendingRefresh).toHaveLength(1);
434
+
435
+ await rt._flushRefresh();
436
+ expect(rt._pendingRefresh).toHaveLength(0);
437
+ });
438
+
439
+ it("미등록 queryKey refresh는 무시되고 경고 출력", async () => {
440
+ const warnSpy = mock(() => {});
441
+ const originalWarn = console.warn;
442
+ console.warn = warnSpy;
443
+
444
+ const rt = buildRealtimeCtx({
445
+ buildCtxForRefresh: () => ({} as any),
446
+ });
447
+ rt.refresh("nonexistent.query.key.xyz");
448
+ await rt._flushRefresh();
449
+
450
+ const hasWarning = warnSpy.mock.calls.some(c =>
451
+ String(c[0]).includes("query not found in registry")
452
+ );
453
+ expect(hasWarning).toBe(true);
454
+
455
+ console.warn = originalWarn;
456
+ });
457
+ });
@@ -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
+ });