@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/crud.d.ts CHANGED
@@ -118,23 +118,23 @@ export declare function parseFilterNode(node: Record<string, unknown>, table: an
118
118
  * ```
119
119
  */
120
120
  export declare function crud<T extends PgTable>(table: T, options?: CrudOptions<T>): {
121
- list: import("./reactive").QueryDef<{
122
- page: import("./v").Validator<number | undefined>;
123
- limit: import("./v").Validator<number | undefined>;
124
- search: import("./v").Validator<string | undefined>;
125
- orderBy: import("./v").Validator<string | undefined>;
126
- orderDir: import("./v").Validator<string | undefined>;
127
- filters: import("./v").Validator<any>;
121
+ list: import("./reactive.js").QueryDef<{
122
+ page: import("./v.js").Validator<number | undefined>;
123
+ limit: import("./v.js").Validator<number | undefined>;
124
+ search: import("./v.js").Validator<string | undefined>;
125
+ orderBy: import("./v.js").Validator<string | undefined>;
126
+ orderDir: import("./v.js").Validator<string | undefined>;
127
+ filters: import("./v.js").Validator<any>;
128
128
  }, {
129
129
  data: any;
130
130
  total: number;
131
131
  }> | undefined;
132
- get: import("./reactive").QueryDef<{
133
- id: import("./v").Validator<string> | import("./v").Validator<number>;
132
+ get: import("./reactive.js").QueryDef<{
133
+ id: import("./v.js").Validator<string> | import("./v.js").Validator<number>;
134
134
  }, any> | undefined;
135
- create: import("./reactive").MutationDef<any, any> | undefined;
136
- update: import("./reactive").MutationDef<any, any> | undefined;
137
- remove: import("./reactive").MutationDef<any, {
135
+ create: import("./reactive.js").MutationDef<any, any> | undefined;
136
+ update: import("./reactive.js").MutationDef<any, any> | undefined;
137
+ remove: import("./reactive.js").MutationDef<any, {
138
138
  success: boolean;
139
139
  }> | undefined;
140
140
  };
package/dist/crud.js CHANGED
@@ -36,9 +36,9 @@
36
36
  */
37
37
  import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns } from "drizzle-orm";
38
38
  import { getTableConfig } from "drizzle-orm/pg-core";
39
- import { query, mutation } from "./reactive";
40
- import { v } from "./v";
41
- import { getOwnerRlsMeta, registerOwnerRls } from "./rls";
39
+ import { query, mutation } from "./reactive.js";
40
+ import { v } from "./v.js";
41
+ import { getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
42
42
  // ─── ownerRls 감지 추적 (부트 로그용) ──────────────────
43
43
  /** crud()가 ownerRls를 자동 감지한 테이블 목록 — 서버 부트 로그에서 출력 */
44
44
  const _ownerRlsTables = [];
@@ -321,7 +321,7 @@ export function crud(table, options) {
321
321
  return await fn(db);
322
322
  }
323
323
  // ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
324
- // Runs inside db.transaction so createRlsDb() can SET LOCAL app.current_user_id for RLS.
324
+ // Runs inside db.transaction so createRlsDb() RLS session vars apply for RLS.
325
325
  // ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
326
326
  // TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
327
327
  async function fetchListWithTotal(db, whereClause, userId) {
package/dist/index.d.ts CHANGED
@@ -4,21 +4,22 @@
4
4
  * Provides: query, mutation, storage, scheduler, auth
5
5
  * All with Convex-compatible DX patterns.
6
6
  */
7
- export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive";
8
- export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
9
- export type { Storage } from "./storage";
10
- export { createScheduler, getSchedulerInfo } from "./scheduler";
11
- export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler";
12
- export { v, parseArgs, GencowValidationError } from "./v";
13
- export type { Validator, Infer, InferArgs } from "./v";
14
- export { withRetry } from "./retry";
15
- export type { RetryOptions } from "./retry";
16
- export { cronJobs } from "./crons";
17
- export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons";
18
- export { defineAuth } from "./auth-config";
19
- export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
20
- export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls";
21
- export type { OwnerRlsMeta } from "./rls";
22
- export { createRlsDb } from "./rls-db";
23
- export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud";
24
- export { crud as gencowCrud } from "./crud";
7
+ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive.js";
8
+ export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
9
+ export type { Storage } from "./storage.js";
10
+ export { createScheduler, getSchedulerInfo } from "./scheduler.js";
11
+ export type { Scheduler, ScheduleOptions, FailedJob, CreateSchedulerOptions, ScheduledJobRecord } from "./scheduler.js";
12
+ export { v, parseArgs, GencowValidationError } from "./v.js";
13
+ export type { Validator, Infer, InferArgs } from "./v.js";
14
+ export { withRetry } from "./retry.js";
15
+ export type { RetryOptions } from "./retry.js";
16
+ export { cronJobs } from "./crons.js";
17
+ export type { CronJobsBuilder, CronJobDef, IntervalOptions, DailyOptions, WeeklyOptions } from "./crons.js";
18
+ export { defineAuth } from "./auth-config.js";
19
+ export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config.js";
20
+ export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
21
+ export type { OwnerRlsMeta } from "./rls.js";
22
+ export { createRlsDb } from "./rls-db.js";
23
+ export type { RlsSessionContext } from "./rls-db.js";
24
+ export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud.js";
25
+ export { crud as gencowCrud } from "./crud.js";
package/dist/index.js CHANGED
@@ -4,15 +4,15 @@
4
4
  * Provides: query, mutation, storage, scheduler, auth
5
5
  * All with Convex-compatible DX patterns.
6
6
  */
7
- export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
8
- export { createScheduler, getSchedulerInfo } from "./scheduler";
9
- export { v, parseArgs, GencowValidationError } from "./v";
10
- export { withRetry } from "./retry";
11
- export { cronJobs } from "./crons";
12
- export { defineAuth } from "./auth-config";
7
+ export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
8
+ export { createScheduler, getSchedulerInfo } from "./scheduler.js";
9
+ export { v, parseArgs, GencowValidationError } from "./v.js";
10
+ export { withRetry } from "./retry.js";
11
+ export { cronJobs } from "./crons.js";
12
+ export { defineAuth } from "./auth-config.js";
13
13
  // ─── RLS + CRUD Factory ───────────
14
- export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls";
15
- export { createRlsDb } from "./rls-db";
16
- export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud";
14
+ export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
15
+ export { createRlsDb } from "./rls-db.js";
16
+ export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud.js";
17
17
  // Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
18
- export { crud as gencowCrud } from "./crud";
18
+ export { crud as gencowCrud } from "./crud.js";
@@ -1,7 +1,7 @@
1
1
  import type { WSContext } from "hono/ws";
2
- import type { Storage } from "./storage";
3
- import type { Scheduler } from "./scheduler";
4
- import { type InferArgs } from "./v";
2
+ import type { Storage } from "./storage.js";
3
+ import type { Scheduler } from "./scheduler.js";
4
+ import { type InferArgs } from "./v.js";
5
5
  export interface UserIdentity {
6
6
  id: string;
7
7
  email: string;
@@ -100,7 +100,7 @@ export interface GencowCtx {
100
100
  /** 실시간 push — ctx.realtime.emit(queryKey, data) */
101
101
  realtime: RealtimeCtx;
102
102
  /** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
103
- retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
103
+ retry: <T>(fn: () => Promise<T>, options?: import("./retry.js").RetryOptions) => Promise<T>;
104
104
  /** AI 헬퍼 */
105
105
  ai?: AIContext;
106
106
  }
package/dist/reactive.js CHANGED
@@ -239,6 +239,12 @@ export function buildRealtimeCtx(options) {
239
239
  }
240
240
  try {
241
241
  // refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
242
+ if (!options?.buildCtxForRefresh) {
243
+ console.warn(`[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
244
+ `Query handler will receive an empty ctx — ctx.db will be undefined. ` +
245
+ `This is a framework configuration error. ` +
246
+ `💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`);
247
+ }
242
248
  const refreshCtx = options?.buildCtxForRefresh?.() ?? {};
243
249
  const result = await queryDef.handler(refreshCtx, {});
244
250
  // emit과 동일한 경로로 push
package/dist/rls-db.d.ts CHANGED
@@ -1,8 +1,47 @@
1
1
  import type { PgDatabase } from "drizzle-orm/pg-core";
2
2
  /**
3
- * Wraps Drizzle so `transaction()` runs `set_config('app.current_user_id', ...)` first (RLS policies).
4
- * Supabase-style session GUC injection.
3
+ * RLS DB wrapper execution paths for `withRlsConnection`:
4
+ * 1. **Reuse outer Drizzle transaction** (`reuseOuterConnection`): same connection, apply GUCs then run `fn`.
5
+ * 2. **Bun SQL** (`session.client.begin`): short transaction around `fn`.
6
+ * 3. **PGlite / drivers with `transaction()`**: delegate to driver helper.
7
+ * 4. **node-pg Pool** (`connect` + `query`): lease a client, BEGIN → set_config → `fn` → COMMIT, or ROLLBACK on error, then `release()`.
8
+ */
9
+ /**
10
+ * Values pushed into PostgreSQL session GUCs (`set_config(..., true)`) for RLS / tenant checks.
11
+ * Optional fields become `app.*` settings; add arbitrary allowed keys via `vars`.
12
+ */
13
+ export type RlsSessionContext = {
14
+ /** `app.current_user_id` — required by `ownerRls()` policies in this repo. */
15
+ userId: string;
16
+ /** When set: `app.current_user_role`. */
17
+ role?: string;
18
+ /** When set: `app.tenant_id`. */
19
+ tenantId?: string;
20
+ /**
21
+ * Extra transaction-local settings: keys must be validated `app.*` GUC names
22
+ * and must not duplicate `app.current_user_id` / `app.current_user_role` / `app.tenant_id`
23
+ * (use the top-level fields for those).
24
+ */
25
+ vars?: Record<string, string>;
26
+ };
27
+ /**
28
+ * pg `Pool.connect()`-style client: BEGIN → apply RLS GUCs → `fn` → COMMIT on success,
29
+ * or ROLLBACK if anything fails (including failed COMMIT). Always `release()` in `finally`.
30
+ *
31
+ * @internal Exported only for unit tests — not re-exported from `@gencow/core` public API.
32
+ */
33
+ export declare function withRlsLeasedConnection<T>(leased: {
34
+ query: (sql: string) => Promise<unknown>;
35
+ release: () => void;
36
+ }, rls: RlsSessionContext, fn: (client: {
37
+ query: (sql: string) => Promise<unknown>;
38
+ release: () => void;
39
+ }) => Promise<T>): Promise<T>;
40
+ /**
41
+ * RLS-scoped Drizzle handle: every query path (`select`, `insert`, `execute`, …) runs
42
+ * `set_config` for the given context in the same DB transaction as the query
43
+ * (PgBouncer–safe transaction-local GUCs).
5
44
  *
6
- * `set_config(..., true)` is transaction-local (safe with PgBouncer transaction pooling).
45
+ * `db.transaction()` still injects the same variables at the start of the callback transaction.
7
46
  */
8
- export declare function createRlsDb(db: PgDatabase<any, any, any>, userId: string): PgDatabase<any, any, any>;
47
+ export declare function createRlsDb(db: PgDatabase<any, any, any>, rls: RlsSessionContext): PgDatabase<any, any, any>;
package/dist/rls-db.js CHANGED
@@ -1,23 +1,228 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { sql } from "drizzle-orm";
3
+ const gucNameRe = /^app\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
4
+ const RESERVED_VARS_KEYS = new Set([
5
+ "app.current_user_id",
6
+ "app.current_user_role",
7
+ "app.tenant_id",
8
+ ]);
9
+ function assertSafeGucName(key) {
10
+ if (!gucNameRe.test(key)) {
11
+ throw new Error(`createRlsDb: GUC name "${key}" is invalid — use lowercase app.* names (e.g. app.org_id)`);
12
+ }
13
+ }
14
+ /** Ordered `(name, value)` pairs for `set_config(name, value, true)`. */
15
+ function rlsSetConfigPairs(rls) {
16
+ const pairs = [["app.current_user_id", rls.userId]];
17
+ if (rls.role !== undefined) {
18
+ pairs.push(["app.current_user_role", rls.role]);
19
+ }
20
+ if (rls.tenantId !== undefined) {
21
+ pairs.push(["app.tenant_id", rls.tenantId]);
22
+ }
23
+ if (rls.vars) {
24
+ for (const [key, value] of Object.entries(rls.vars)) {
25
+ assertSafeGucName(key);
26
+ if (RESERVED_VARS_KEYS.has(key)) {
27
+ throw new Error(`createRlsDb: vars must not set "${key}" — use userId, role, or tenantId on the context object`);
28
+ }
29
+ pairs.push([key, value]);
30
+ }
31
+ }
32
+ return pairs;
33
+ }
34
+ async function forEachSetConfig(rls, setOne) {
35
+ for (const [name, value] of rlsSetConfigPairs(rls)) {
36
+ await setOne(name, value);
37
+ }
38
+ }
39
+ /**
40
+ * While a prepared query runs, nested queries reuse this client (same outer `begin` / tx).
41
+ */
42
+ const rlsExecClient = new AsyncLocalStorage();
43
+ function isDrizzleTransactionDb(db) {
44
+ const d = db;
45
+ if (typeof d?.rollback === "function") {
46
+ return true;
47
+ }
48
+ const name = d?.constructor?.name;
49
+ return typeof name === "string" && name.includes("Transaction");
50
+ }
51
+ async function execSetConfig(client, name, value) {
52
+ if (typeof client?.unsafe === "function") {
53
+ await client.unsafe(`select set_config($1::text, $2::text, true)`, [name, value]);
54
+ return;
55
+ }
56
+ if (typeof client?.query === "function") {
57
+ await client.query(`select set_config($1::text, $2::text, true)`, [name, value]);
58
+ return;
59
+ }
60
+ throw new Error("createRlsDb: unsupported SQL driver (expected Bun SQL, node-pg client/pool, or PGlite)");
61
+ }
62
+ async function applyRlsSessionVars(client, rls) {
63
+ await forEachSetConfig(rls, (name, value) => execSetConfig(client, name, value));
64
+ }
65
+ async function injectRlsVarsOnTx(tx, rls) {
66
+ await forEachSetConfig(rls, (name, value) => tx.execute(sql `SELECT set_config(${name}, ${value}, true)`));
67
+ }
68
+ /**
69
+ * pg `Pool.connect()`-style client: BEGIN → apply RLS GUCs → `fn` → COMMIT on success,
70
+ * or ROLLBACK if anything fails (including failed COMMIT). Always `release()` in `finally`.
71
+ *
72
+ * @internal Exported only for unit tests — not re-exported from `@gencow/core` public API.
73
+ */
74
+ export async function withRlsLeasedConnection(leased, rls, fn) {
75
+ try {
76
+ await leased.query("begin");
77
+ await applyRlsSessionVars(leased, rls);
78
+ const result = await rlsExecClient.run(leased, () => fn(leased));
79
+ await leased.query("commit");
80
+ return result;
81
+ }
82
+ catch (e) {
83
+ try {
84
+ await leased.query("rollback");
85
+ }
86
+ catch {
87
+ /* ignore */
88
+ }
89
+ throw e;
90
+ }
91
+ finally {
92
+ leased.release();
93
+ }
94
+ }
95
+ /**
96
+ * Runs `fn` with a connection that has transaction-local RLS session variables set.
97
+ * Pool / autocommit: opens a short transaction (Bun `begin`, PGlite `transaction`).
98
+ * When `db` is already a Drizzle transaction object, reuses that connection (no nested top-level tx).
99
+ */
100
+ async function withRlsConnection(session, rls, reuseOuterConnection, fn) {
101
+ if (reuseOuterConnection) {
102
+ const c = session.client;
103
+ return rlsExecClient.run(c, async () => {
104
+ await applyRlsSessionVars(c, rls);
105
+ return fn(c);
106
+ });
107
+ }
108
+ const c = session.client;
109
+ const runInner = async (client) => {
110
+ await applyRlsSessionVars(client, rls);
111
+ return rlsExecClient.run(client, () => fn(client));
112
+ };
113
+ if (typeof c.begin === "function") {
114
+ return c.begin(runInner);
115
+ }
116
+ if (typeof c.transaction === "function") {
117
+ return c.transaction(runInner);
118
+ }
119
+ if (typeof c.connect === "function" && typeof c.query === "function") {
120
+ const leased = await c.connect();
121
+ return withRlsLeasedConnection(leased, rls, fn);
122
+ }
123
+ return runInner(c);
124
+ }
125
+ function wrapPreparedQuery(pq, session, rls, reuseOuterConnection) {
126
+ const origExecute = pq.execute.bind(pq);
127
+ const origAll = pq.all.bind(pq);
128
+ pq.execute = async (placeholderValues) => {
129
+ const active = rlsExecClient.getStore();
130
+ if (active) {
131
+ const prev = pq.client;
132
+ pq.client = active;
133
+ try {
134
+ return await origExecute(placeholderValues);
135
+ }
136
+ finally {
137
+ pq.client = prev;
138
+ }
139
+ }
140
+ return withRlsConnection(session, rls, reuseOuterConnection, async (client) => {
141
+ const prev = pq.client;
142
+ pq.client = client;
143
+ try {
144
+ return await origExecute(placeholderValues);
145
+ }
146
+ finally {
147
+ pq.client = prev;
148
+ }
149
+ });
150
+ };
151
+ pq.all = async (placeholderValues) => {
152
+ const active = rlsExecClient.getStore();
153
+ if (active) {
154
+ const prev = pq.client;
155
+ pq.client = active;
156
+ try {
157
+ return await origAll(placeholderValues);
158
+ }
159
+ finally {
160
+ pq.client = prev;
161
+ }
162
+ }
163
+ return withRlsConnection(session, rls, reuseOuterConnection, async (client) => {
164
+ const prev = pq.client;
165
+ pq.client = client;
166
+ try {
167
+ return await origAll(placeholderValues);
168
+ }
169
+ finally {
170
+ pq.client = prev;
171
+ }
172
+ });
173
+ };
174
+ }
175
+ function wrapSession(session, rls, reuseOuterConnection) {
176
+ return new Proxy(session, {
177
+ get(sTarget, sProp, sRecv) {
178
+ if (sProp === "prepareQuery") {
179
+ return (...args) => {
180
+ const pq = sTarget.prepareQuery(...args);
181
+ wrapPreparedQuery(pq, sTarget, rls, reuseOuterConnection);
182
+ return pq;
183
+ };
184
+ }
185
+ const v = Reflect.get(sTarget, sProp, sRecv);
186
+ return typeof v === "function" ? v.bind(sTarget) : v;
187
+ },
188
+ });
189
+ }
2
190
  /**
3
- * Wraps Drizzle so `transaction()` runs `set_config('app.current_user_id', ...)` first (RLS policies).
4
- * Supabase-style session GUC injection.
191
+ * RLS-scoped Drizzle handle: every query path (`select`, `insert`, `execute`, ) runs
192
+ * `set_config` for the given context in the same DB transaction as the query
193
+ * (PgBouncer–safe transaction-local GUCs).
5
194
  *
6
- * `set_config(..., true)` is transaction-local (safe with PgBouncer transaction pooling).
195
+ * `db.transaction()` still injects the same variables at the start of the callback transaction.
7
196
  */
8
- export function createRlsDb(db, userId) {
197
+ export function createRlsDb(db, rls) {
198
+ const reuseOuterConnection = isDrizzleTransactionDb(db);
199
+ const baseSession = db.session;
200
+ const wrappedSession = wrapSession(baseSession, rls, reuseOuterConnection);
9
201
  return new Proxy(db, {
10
202
  get(target, prop, receiver) {
203
+ if (prop === "session") {
204
+ return wrappedSession;
205
+ }
206
+ if (prop === "_") {
207
+ const inner = target._;
208
+ return new Proxy(inner, {
209
+ get(i, p, r) {
210
+ if (p === "session")
211
+ return wrappedSession;
212
+ return Reflect.get(i, p, r);
213
+ },
214
+ });
215
+ }
11
216
  if (prop === "transaction") {
12
217
  return async (callback, ...rest) => {
13
218
  return await target.transaction(async (tx) => {
14
- await tx.execute(sql `SELECT set_config('app.current_user_id', ${userId}, true)`);
219
+ await injectRlsVarsOnTx(tx, rls);
15
220
  return await callback(tx);
16
221
  }, ...rest);
17
222
  };
18
223
  }
19
224
  const value = Reflect.get(target, prop, receiver);
20
- return typeof value === "function" ? value.bind(target) : value;
21
- }
225
+ return typeof value === "function" ? value.bind(receiver) : value;
226
+ },
22
227
  });
23
228
  }
package/dist/rls.d.ts CHANGED
@@ -24,7 +24,7 @@ export declare function registerOwnerRls(table: PgTable, meta: OwnerRlsMeta): vo
24
24
  * 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
25
25
  * PGlite + PostgreSQL 양쪽에서 동작.
26
26
  * - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
27
- * createRlsDb() transaction() 내에서 set_config 주입.
27
+ * createRlsDb() RlsSessionContext로 set_config 주입.
28
28
  *
29
29
  * @example
30
30
  * ```ts
package/dist/rls.js CHANGED
@@ -25,7 +25,7 @@ export function registerOwnerRls(table, meta) {
25
25
  * 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
26
26
  * PGlite + PostgreSQL 양쪽에서 동작.
27
27
  * - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
28
- * createRlsDb() transaction() 내에서 set_config 주입.
28
+ * createRlsDb() RlsSessionContext로 set_config 주입.
29
29
  *
30
30
  * @example
31
31
  * ```ts
@@ -4,6 +4,28 @@ export interface ScheduleOptions {
4
4
  /** 실패 시 호출할 action 이름 (dead-letter 패턴) */
5
5
  onError?: string;
6
6
  }
7
+ /** scheduled job의 DB 영속화를 위한 콜백 */
8
+ export interface ScheduledJobRecord {
9
+ id: string;
10
+ action: string;
11
+ args: unknown;
12
+ runAt: Date;
13
+ onErrorAction?: string;
14
+ }
15
+ /**
16
+ * createScheduler 옵션.
17
+ *
18
+ * persistJob/removeJob이 주어지면 runAfter()가 setTimeout 대신 DB에 영속화.
19
+ * 외부 폴러(Platform Cron Runner)가 주기적으로 due된 job을 조회하여 실행.
20
+ *
21
+ * 콜백 미설정 시 기존 인메모리 setTimeout 동작 유지 (로컬 dev).
22
+ */
23
+ export interface CreateSchedulerOptions {
24
+ /** Job을 DB에 영속화 (INSERT) */
25
+ persistJob?: (job: ScheduledJobRecord) => Promise<void>;
26
+ /** Job을 DB에서 제거 (DELETE) — cancel/완료 시 */
27
+ removeJob?: (jobId: string) => Promise<boolean>;
28
+ }
7
29
  /** 실패한 작업의 기록 */
8
30
  export interface FailedJob {
9
31
  id: string;
@@ -29,18 +51,26 @@ export interface Scheduler {
29
51
  /**
30
52
  * Create a scheduler instance — Convex scheduler 패턴 재현
31
53
  *
54
+ * @param options persistJob/removeJob 콜백을 전달하면 durable mode 활성화.
55
+ * BaaS 모드에서는 서버가 DB 콜백을 전달하여 sleep-safe 보장.
56
+ * 로컬 dev에서는 콜백 없이 호출하여 기존 setTimeout 동작.
57
+ *
32
58
  * @example
59
+ * // 로컬 dev (인메모리)
33
60
  * const scheduler = createScheduler();
34
61
  *
62
+ * // BaaS (DB-backed durable)
63
+ * const scheduler = createScheduler({
64
+ * persistJob: async (job) => { await rawSql('INSERT INTO ...', ...) },
65
+ * removeJob: async (id) => { await rawSql('DELETE FROM ...', [id]) },
66
+ * });
67
+ *
35
68
  * // Register actions
36
69
  * scheduler.registerAction('emails.send', async (args) => { ... });
37
70
  *
38
- * // Schedule (Convex-style)
71
+ * // Schedule (Convex-style) — durable mode에서는 DB에 영속화
39
72
  * scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
40
73
  *
41
- * // Schedule with error callback (dead-letter 패턴)
42
- * scheduler.runAfter(0, 'pipeline.step2', args, { onError: 'pipeline.onStepError' });
43
- *
44
74
  * // Cron (Convex-style)
45
75
  * scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
46
76
  */
@@ -60,5 +90,5 @@ export declare function getSchedulerInfo(): {
60
90
  pendingJobs: PendingJobEntry[];
61
91
  failedJobs: FailedJob[];
62
92
  };
63
- export declare function createScheduler(): Scheduler;
93
+ export declare function createScheduler(options?: CreateSchedulerOptions): Scheduler;
64
94
  export {};