@gencow/core 0.1.22 → 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 (32) hide show
  1. package/dist/crud.js +1 -1
  2. package/dist/index.d.ts +2 -1
  3. package/dist/reactive.js +6 -0
  4. package/dist/rls-db.d.ts +43 -4
  5. package/dist/rls-db.js +212 -7
  6. package/dist/rls.d.ts +1 -1
  7. package/dist/rls.js +1 -1
  8. package/dist/scheduler.d.ts +35 -5
  9. package/dist/scheduler.js +83 -42
  10. package/package.json +1 -1
  11. package/src/__tests__/crud-owner-rls.test.ts +6 -6
  12. package/src/__tests__/fixtures/basic/migrations/{0000_faithful_silver_sable.sql → 0000_last_warstar.sql} +9 -0
  13. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +60 -1
  14. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +2 -2
  15. package/src/__tests__/fixtures/basic/schema.ts +19 -3
  16. package/src/__tests__/helpers/basic-rls-fixture.ts +133 -0
  17. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +1 -1
  18. package/src/__tests__/reactive.test.ts +161 -0
  19. package/src/__tests__/rls-crud-basic.test.ts +120 -161
  20. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +117 -0
  21. package/src/__tests__/rls-custom-mutation-handlers.test.ts +189 -0
  22. package/src/__tests__/rls-custom-query-handlers.test.ts +128 -0
  23. package/src/__tests__/rls-db-leased-connection.test.ts +122 -0
  24. package/src/__tests__/rls-session-and-policies.test.ts +246 -0
  25. package/src/__tests__/scheduler-durable-v2.test.ts +270 -0
  26. package/src/__tests__/scheduler-durable.test.ts +173 -0
  27. package/src/crud.ts +1 -1
  28. package/src/index.ts +2 -1
  29. package/src/reactive.ts +8 -0
  30. package/src/rls-db.ts +277 -10
  31. package/src/rls.ts +1 -1
  32. package/src/scheduler.ts +124 -46
@@ -0,0 +1,270 @@
1
+ /**
2
+ * packages/core/src/__tests__/scheduler-durable-v2.test.ts
3
+ *
4
+ * Scheduler Durable v2 테스트 — Platform DB 중앙화 구현 검증.
5
+ *
6
+ * DB/HTTP 의존 없이 검증 가능한 핵심 로직:
7
+ * - persistJob에 올바른 필드가 전달되는지
8
+ * - args가 persistJob 콜백까지 정확히 전달되는지
9
+ * - 다수의 job 동시 등록 시 id 충돌 없음
10
+ * - removeJob 실패 시 graceful 처리
11
+ * - runAfter(0) 즉시 실행 케이스
12
+ * - 대형 args 직렬화 안정성
13
+ *
14
+ * Run: bun test packages/core/src/__tests__/scheduler-durable-v2.test.ts
15
+ */
16
+
17
+ import { describe, it, expect } from "bun:test";
18
+ import { createScheduler } from "../scheduler";
19
+ import type { ScheduledJobRecord } from "../scheduler";
20
+
21
+ describe("Scheduler Durable v2 — Platform DB 중앙화", () => {
22
+ // ── args 전달 정확성 ──
23
+
24
+ it("persistJob에 args가 정확히 전달된다 (중첩 객체)", async () => {
25
+ const persisted: ScheduledJobRecord[] = [];
26
+
27
+ const scheduler = createScheduler({
28
+ persistJob: async (job) => { persisted.push(job); },
29
+ removeJob: async () => true,
30
+ });
31
+
32
+ scheduler.registerAction("deep.args", async () => {});
33
+
34
+ const complexArgs = {
35
+ nested: { deeply: { value: 42 } },
36
+ array: [1, 2, { x: "y" }],
37
+ unicode: "한글테스트 🎯",
38
+ nullValue: null,
39
+ boolValue: false,
40
+ zero: 0,
41
+ emptyString: "",
42
+ };
43
+
44
+ scheduler.runAfter(1000, "deep.args", complexArgs);
45
+ await new Promise((r) => setTimeout(r, 50));
46
+
47
+ expect(persisted.length).toBe(1);
48
+ expect(persisted[0].args).toEqual(complexArgs);
49
+ });
50
+
51
+ it("persistJob에 args 미전달 시 undefined로 전달", async () => {
52
+ const persisted: ScheduledJobRecord[] = [];
53
+
54
+ const scheduler = createScheduler({
55
+ persistJob: async (job) => { persisted.push(job); },
56
+ removeJob: async () => true,
57
+ });
58
+
59
+ scheduler.registerAction("no.args", async () => {});
60
+ scheduler.runAfter(1000, "no.args"); // args 생략
61
+
62
+ await new Promise((r) => setTimeout(r, 50));
63
+
64
+ expect(persisted.length).toBe(1);
65
+ // args는 undefined 또는 빈 값이어야 함 (Platform에서 {} default 처리)
66
+ expect(persisted[0].args === undefined || persisted[0].args === null || JSON.stringify(persisted[0].args) === "{}").toBe(true);
67
+ });
68
+
69
+ // ── 동시 등록 id 고유성 ──
70
+
71
+ it("10개 job 동시 등록 시 모든 id가 고유하다", async () => {
72
+ const persisted: ScheduledJobRecord[] = [];
73
+
74
+ const scheduler = createScheduler({
75
+ persistJob: async (job) => { persisted.push(job); },
76
+ removeJob: async () => true,
77
+ });
78
+
79
+ for (let i = 0; i < 10; i++) {
80
+ scheduler.registerAction(`batch.${i}`, async () => {});
81
+ }
82
+
83
+ const ids: string[] = [];
84
+ for (let i = 0; i < 10; i++) {
85
+ ids.push(scheduler.runAfter(1000, `batch.${i}`, { idx: i }));
86
+ }
87
+
88
+ await new Promise((r) => setTimeout(r, 100));
89
+
90
+ // 모든 id 고유
91
+ const uniqueIds = new Set(ids);
92
+ expect(uniqueIds.size).toBe(10);
93
+
94
+ // 모든 persistJob 호출됨
95
+ expect(persisted.length).toBe(10);
96
+ });
97
+
98
+ // ── runAfter(0) — 즉시 실행 ──
99
+
100
+ it("runAfter(0) — durable mode에서도 즉시 persistJob 호출", async () => {
101
+ const persisted: ScheduledJobRecord[] = [];
102
+
103
+ const scheduler = createScheduler({
104
+ persistJob: async (job) => { persisted.push(job); },
105
+ removeJob: async () => true,
106
+ });
107
+
108
+ scheduler.registerAction("instant", async () => {});
109
+
110
+ const beforeTime = Date.now();
111
+ scheduler.runAfter(0, "instant", { immediate: true });
112
+
113
+ await new Promise((r) => setTimeout(r, 50));
114
+
115
+ expect(persisted.length).toBe(1);
116
+ expect(persisted[0].args).toEqual({ immediate: true });
117
+ // runAt은 현재 시간과 거의 동일해야 함 (±2초 오차 허용)
118
+ expect(Math.abs(persisted[0].runAt.getTime() - beforeTime)).toBeLessThan(2000);
119
+ });
120
+
121
+ // ── removeJob 실패 시 graceful 처리 ──
122
+
123
+ it("removeJob 실패 시 cancel()이 false 반환 (throw 안 함)", async () => {
124
+ const scheduler = createScheduler({
125
+ persistJob: async () => {},
126
+ removeJob: async () => {
127
+ throw new Error("DB connection lost");
128
+ },
129
+ });
130
+
131
+ scheduler.registerAction("fail.cancel", async () => {});
132
+ const id = scheduler.runAfter(10000, "fail.cancel");
133
+
134
+ await new Promise((r) => setTimeout(r, 50));
135
+
136
+ // suppress expected error
137
+ const orig = console.error;
138
+ console.error = () => {};
139
+ const result = scheduler.cancel(id);
140
+ console.error = orig;
141
+
142
+ // removeJob 실패 시 return false, throw 안 함
143
+ expect(typeof result).toBe("boolean");
144
+ });
145
+
146
+ // ── 대형 args 안정성 ──
147
+
148
+ it("대형 args (10KB) 직렬화 안정성", async () => {
149
+ const persisted: ScheduledJobRecord[] = [];
150
+
151
+ const scheduler = createScheduler({
152
+ persistJob: async (job) => { persisted.push(job); },
153
+ removeJob: async () => true,
154
+ });
155
+
156
+ scheduler.registerAction("large.payload", async () => {});
157
+
158
+ // ~10KB args
159
+ const largeArgs = {
160
+ data: "x".repeat(10_000),
161
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `item-${i}` })),
162
+ };
163
+
164
+ scheduler.runAfter(1000, "large.payload", largeArgs);
165
+ await new Promise((r) => setTimeout(r, 50));
166
+
167
+ expect(persisted.length).toBe(1);
168
+ expect((persisted[0].args as any).data.length).toBe(10_000);
169
+ expect((persisted[0].args as any).items.length).toBe(100);
170
+ });
171
+
172
+ // ── onError + args 조합 ──
173
+
174
+ it("onError와 args가 함께 persistJob에 전달된다", async () => {
175
+ const persisted: ScheduledJobRecord[] = [];
176
+
177
+ const scheduler = createScheduler({
178
+ persistJob: async (job) => { persisted.push(job); },
179
+ removeJob: async () => true,
180
+ });
181
+
182
+ scheduler.registerAction("pipeline.step1", async () => {});
183
+ scheduler.registerAction("pipeline.onError", async () => {});
184
+
185
+ scheduler.runAfter(5000, "pipeline.step1", { step: 1, data: "test" }, { onError: "pipeline.onError" });
186
+
187
+ await new Promise((r) => setTimeout(r, 50));
188
+
189
+ expect(persisted.length).toBe(1);
190
+ expect(persisted[0].action).toBe("pipeline.step1");
191
+ expect(persisted[0].args).toEqual({ step: 1, data: "test" });
192
+ expect(persisted[0].onErrorAction).toBe("pipeline.onError");
193
+ });
194
+
195
+ // ── runAt + durable 조합 ──
196
+
197
+ it("runAt(과거 시점) — durable mode에서도 persistJob 호출 (즉시 실행 대상)", async () => {
198
+ const persisted: ScheduledJobRecord[] = [];
199
+
200
+ const scheduler = createScheduler({
201
+ persistJob: async (job) => { persisted.push(job); },
202
+ removeJob: async () => true,
203
+ });
204
+
205
+ scheduler.registerAction("past.task", async () => {});
206
+
207
+ const pastTime = new Date(Date.now() - 60_000); // 1분 전
208
+ scheduler.runAt(pastTime, "past.task", { expired: true });
209
+
210
+ await new Promise((r) => setTimeout(r, 50));
211
+
212
+ expect(persisted.length).toBe(1);
213
+ expect(persisted[0].args).toEqual({ expired: true });
214
+ // runAt이 과거여도 persistJob은 호출되어야 함 (폴러가 즉시 픽업)
215
+ });
216
+
217
+ // ── 다중 cancel ──
218
+
219
+ it("같은 job을 2번 cancel — 첫 번째만 removeJob 성공", async () => {
220
+ let removeCount = 0;
221
+
222
+ const scheduler = createScheduler({
223
+ persistJob: async () => {},
224
+ removeJob: async () => {
225
+ removeCount++;
226
+ return removeCount === 1; // 첫 번째만 true
227
+ },
228
+ });
229
+
230
+ scheduler.registerAction("double.cancel", async () => {});
231
+ const id = scheduler.runAfter(10000, "double.cancel");
232
+
233
+ await new Promise((r) => setTimeout(r, 50));
234
+
235
+ const r1 = scheduler.cancel(id);
236
+ await new Promise((r) => setTimeout(r, 20));
237
+ const r2 = scheduler.cancel(id);
238
+
239
+ // 첫 번째 cancel은 성공 (pendingJobs에서 제거)
240
+ expect(r1).toBe(true);
241
+ // 두 번째 cancel은 이미 제거되었으므로 false
242
+ expect(r2).toBe(false);
243
+ });
244
+
245
+ // ── persistJob 순서 보장 ──
246
+
247
+ it("연속 runAfter 호출 시 persistJob 순서 보장", async () => {
248
+ const order: string[] = [];
249
+
250
+ const scheduler = createScheduler({
251
+ persistJob: async (job) => {
252
+ order.push(job.action);
253
+ },
254
+ removeJob: async () => true,
255
+ });
256
+
257
+ scheduler.registerAction("a", async () => {});
258
+ scheduler.registerAction("b", async () => {});
259
+ scheduler.registerAction("c", async () => {});
260
+
261
+ scheduler.runAfter(1000, "a");
262
+ scheduler.runAfter(2000, "b");
263
+ scheduler.runAfter(3000, "c");
264
+
265
+ await new Promise((r) => setTimeout(r, 100));
266
+
267
+ // persistJob은 호출 순서대로 실행되어야 함
268
+ expect(order).toEqual(["a", "b", "c"]);
269
+ });
270
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * packages/core/src/__tests__/scheduler-durable.test.ts
3
+ *
4
+ * Scheduler Durable Mode 테스트 — createScheduler({ persistJob, removeJob })
5
+ * DB 콜백이 제공되면 runAfter()가 setTimeout 대신 persistJob을 호출하고,
6
+ * cancel()이 removeJob을 호출하는지 검증.
7
+ *
8
+ * Run: bun test packages/core/src/__tests__/scheduler-durable.test.ts
9
+ */
10
+
11
+ import { describe, it, expect } from "bun:test";
12
+ import { createScheduler } from "../scheduler";
13
+ import type { ScheduledJobRecord } from "../scheduler";
14
+
15
+ describe("Scheduler Durable Mode — persistJob/removeJob", () => {
16
+ it("durable mode에서 runAfter → persistJob 콜백 호출", async () => {
17
+ const persisted: ScheduledJobRecord[] = [];
18
+
19
+ const scheduler = createScheduler({
20
+ persistJob: async (job) => {
21
+ persisted.push(job);
22
+ },
23
+ removeJob: async () => true,
24
+ });
25
+
26
+ scheduler.registerAction("test.durable", async () => {});
27
+ const id = scheduler.runAfter(5000, "test.durable", { key: "value" });
28
+
29
+ // persistJob은 비동기이므로 약간 대기
30
+ await new Promise((r) => setTimeout(r, 50));
31
+
32
+ expect(persisted.length).toBe(1);
33
+ expect(persisted[0].id).toBe(id);
34
+ expect(persisted[0].action).toBe("test.durable");
35
+ expect(persisted[0].args).toEqual({ key: "value" });
36
+ expect(persisted[0].runAt).toBeInstanceOf(Date);
37
+ expect(persisted[0].runAt.getTime()).toBeGreaterThan(Date.now() + 4000); // ~5초 후
38
+ });
39
+
40
+ it("durable mode에서 runAfter → setTimeout 실행 안 함 (외부 폴러에 위임)", async () => {
41
+ let actionExecuted = false;
42
+
43
+ const scheduler = createScheduler({
44
+ persistJob: async () => {},
45
+ removeJob: async () => true,
46
+ });
47
+
48
+ scheduler.registerAction("no.exec", async () => {
49
+ actionExecuted = true;
50
+ });
51
+
52
+ scheduler.runAfter(50, "no.exec");
53
+
54
+ // 100ms 대기 — durable mode에서는 action이 실행되지 않아야 함
55
+ await new Promise((r) => setTimeout(r, 150));
56
+
57
+ expect(actionExecuted).toBe(false);
58
+ });
59
+
60
+ it("durable mode에서 cancel → removeJob 콜백 호출", async () => {
61
+ const removed: string[] = [];
62
+
63
+ const scheduler = createScheduler({
64
+ persistJob: async () => {},
65
+ removeJob: async (jobId) => {
66
+ removed.push(jobId);
67
+ return true;
68
+ },
69
+ });
70
+
71
+ scheduler.registerAction("noop", async () => {});
72
+ const id = scheduler.runAfter(10000, "noop");
73
+
74
+ // persistJob 완료 대기
75
+ await new Promise((r) => setTimeout(r, 50));
76
+
77
+ scheduler.cancel(id);
78
+
79
+ // removeJob은 비동기이므로 대기
80
+ await new Promise((r) => setTimeout(r, 50));
81
+
82
+ expect(removed.length).toBeGreaterThanOrEqual(1);
83
+ expect(removed).toContain(id);
84
+ });
85
+
86
+ it("durable mode에서 onError action 이름이 persistJob에 전달", async () => {
87
+ const persisted: ScheduledJobRecord[] = [];
88
+
89
+ const scheduler = createScheduler({
90
+ persistJob: async (job) => {
91
+ persisted.push(job);
92
+ },
93
+ removeJob: async () => true,
94
+ });
95
+
96
+ scheduler.registerAction("step1", async () => {});
97
+ scheduler.registerAction("onFail", async () => {});
98
+
99
+ scheduler.runAfter(1000, "step1", {}, { onError: "onFail" });
100
+
101
+ await new Promise((r) => setTimeout(r, 50));
102
+
103
+ expect(persisted.length).toBe(1);
104
+ expect(persisted[0].onErrorAction).toBe("onFail");
105
+ });
106
+
107
+ it("persistJob 실패 시 인메모리 fallback으로 실행", async () => {
108
+ let actionExecuted = false;
109
+
110
+ const scheduler = createScheduler({
111
+ persistJob: async () => {
112
+ throw new Error("DB connection failed");
113
+ },
114
+ removeJob: async () => true,
115
+ });
116
+
117
+ scheduler.registerAction("fallback.test", async () => {
118
+ actionExecuted = true;
119
+ });
120
+
121
+ // suppress expected error log
122
+ const origError = console.error;
123
+ console.error = () => {};
124
+
125
+ scheduler.runAfter(50, "fallback.test");
126
+
127
+ // persistJob 실패 → fallback → setTimeout(50ms) → 실행
128
+ await new Promise((r) => setTimeout(r, 300));
129
+
130
+ console.error = origError;
131
+
132
+ expect(actionExecuted).toBe(true);
133
+ });
134
+
135
+ it("콜백 미설정 시 기존 인메모리 mode 유지", async () => {
136
+ let executed = false;
137
+
138
+ const scheduler = createScheduler(); // no options
139
+
140
+ scheduler.registerAction("mem.test", async () => {
141
+ executed = true;
142
+ });
143
+
144
+ scheduler.runAfter(50, "mem.test");
145
+
146
+ await new Promise((r) => setTimeout(r, 150));
147
+
148
+ expect(executed).toBe(true);
149
+ });
150
+
151
+ it("runAt도 durable mode에서 persistJob 호출", async () => {
152
+ const persisted: ScheduledJobRecord[] = [];
153
+
154
+ const scheduler = createScheduler({
155
+ persistJob: async (job) => {
156
+ persisted.push(job);
157
+ },
158
+ removeJob: async () => true,
159
+ });
160
+
161
+ scheduler.registerAction("future.task", async () => {});
162
+
163
+ const futureTime = new Date(Date.now() + 60_000); // 1분 후
164
+ scheduler.runAt(futureTime, "future.task", { x: 1 });
165
+
166
+ await new Promise((r) => setTimeout(r, 50));
167
+
168
+ expect(persisted.length).toBe(1);
169
+ expect(persisted[0].action).toBe("future.task");
170
+ // runAt → runAfter 변환이므로 runAt 시간과 근사해야 함
171
+ expect(Math.abs(persisted[0].runAt.getTime() - futureTime.getTime())).toBeLessThan(1000);
172
+ });
173
+ });
package/src/crud.ts CHANGED
@@ -412,7 +412,7 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
412
412
  }
413
413
 
414
414
  // ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
415
- // Runs inside db.transaction so createRlsDb() can SET LOCAL app.current_user_id for RLS.
415
+ // Runs inside db.transaction so createRlsDb() RLS session vars apply for RLS.
416
416
  // ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
417
417
  // TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
418
418
  async function fetchListWithTotal(db: any, whereClause?: SQL, userId?: string) {
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeC
9
9
  export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
10
10
  export type { Storage } from "./storage.js";
11
11
  export { createScheduler, getSchedulerInfo } from "./scheduler.js";
12
- export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler.js";
12
+ export type { Scheduler, ScheduleOptions, FailedJob, CreateSchedulerOptions, ScheduledJobRecord } from "./scheduler.js";
13
13
  export { v, parseArgs, GencowValidationError } from "./v.js";
14
14
  export type { Validator, Infer, InferArgs } from "./v.js";
15
15
  export { withRetry } from "./retry.js";
@@ -23,6 +23,7 @@ export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config.js";
23
23
  export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
24
24
  export type { OwnerRlsMeta } from "./rls.js";
25
25
  export { createRlsDb } from "./rls-db.js";
26
+ export type { RlsSessionContext } from "./rls-db.js";
26
27
  export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud.js";
27
28
 
28
29
  // Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
package/src/reactive.ts CHANGED
@@ -477,6 +477,14 @@ export function buildRealtimeCtx(options?: {
477
477
  }
478
478
  try {
479
479
  // refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
480
+ if (!options?.buildCtxForRefresh) {
481
+ console.warn(
482
+ `[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
483
+ `Query handler will receive an empty ctx — ctx.db will be undefined. ` +
484
+ `This is a framework configuration error. ` +
485
+ `💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`
486
+ );
487
+ }
480
488
  const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
481
489
  const result = await queryDef.handler(refreshCtx, {});
482
490