@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.
- package/dist/crud.d.ts +12 -12
- package/dist/crud.js +4 -4
- package/dist/index.d.ts +19 -18
- package/dist/index.js +10 -10
- package/dist/reactive.d.ts +4 -4
- package/dist/reactive.js +6 -0
- package/dist/rls-db.d.ts +43 -4
- package/dist/rls-db.js +212 -7
- package/dist/rls.d.ts +1 -1
- package/dist/rls.js +1 -1
- package/dist/scheduler.d.ts +35 -5
- package/dist/scheduler.js +83 -42
- package/dist/server.d.ts +5 -5
- package/dist/server.js +4 -4
- package/package.json +43 -42
- package/src/__tests__/crud-owner-rls.test.ts +6 -6
- package/src/__tests__/fixtures/basic/migrations/{0000_faithful_silver_sable.sql → 0000_last_warstar.sql} +9 -0
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +60 -1
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +2 -2
- package/src/__tests__/fixtures/basic/schema.ts +19 -3
- package/src/__tests__/helpers/basic-rls-fixture.ts +133 -0
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +1 -1
- package/src/__tests__/reactive.test.ts +161 -0
- package/src/__tests__/rls-crud-basic.test.ts +120 -161
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +117 -0
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +189 -0
- package/src/__tests__/rls-custom-query-handlers.test.ts +128 -0
- package/src/__tests__/rls-db-leased-connection.test.ts +122 -0
- package/src/__tests__/rls-session-and-policies.test.ts +246 -0
- package/src/__tests__/scheduler-durable-v2.test.ts +270 -0
- package/src/__tests__/scheduler-durable.test.ts +173 -0
- package/src/crud.ts +4 -4
- package/src/index.ts +19 -18
- package/src/reactive.ts +12 -4
- package/src/rls-db.ts +277 -10
- package/src/rls.ts +1 -1
- package/src/scheduler.ts +124 -46
- 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()
|
|
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 {
|
|
24
|
-
export { 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";
|
package/dist/reactive.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
* `
|
|
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>,
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
* `
|
|
195
|
+
* `db.transaction()` still injects the same variables at the start of the callback transaction.
|
|
7
196
|
*/
|
|
8
|
-
export function createRlsDb(db,
|
|
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
|
|
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(
|
|
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()
|
|
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()
|
|
28
|
+
* createRlsDb()가 RlsSessionContext로 set_config 주입.
|
|
29
29
|
*
|
|
30
30
|
* @example
|
|
31
31
|
* ```ts
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -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 {};
|