@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,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,
@@ -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
+ });