@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.
- package/dist/crud.js +1 -1
- package/dist/index.d.ts +2 -1
- 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/package.json +1 -1
- 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 +1 -1
- package/src/index.ts +2 -1
- package/src/reactive.ts +8 -0
- package/src/rls-db.ts +277 -10
- package/src/rls.ts +1 -1
- package/src/scheduler.ts +124 -46
package/dist/crud.js
CHANGED
|
@@ -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
|
@@ -8,7 +8,7 @@ export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeC
|
|
|
8
8
|
export { query, mutation, httpAction, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive.js";
|
|
9
9
|
export type { Storage } from "./storage.js";
|
|
10
10
|
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
11
|
-
export type { Scheduler, ScheduleOptions, FailedJob } from "./scheduler.js";
|
|
11
|
+
export type { Scheduler, ScheduleOptions, FailedJob, CreateSchedulerOptions, ScheduledJobRecord } from "./scheduler.js";
|
|
12
12
|
export { v, parseArgs, GencowValidationError } from "./v.js";
|
|
13
13
|
export type { Validator, Infer, InferArgs } from "./v.js";
|
|
14
14
|
export { withRetry } from "./retry.js";
|
|
@@ -20,5 +20,6 @@ export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config.js";
|
|
|
20
20
|
export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls.js";
|
|
21
21
|
export type { OwnerRlsMeta } from "./rls.js";
|
|
22
22
|
export { createRlsDb } from "./rls-db.js";
|
|
23
|
+
export type { RlsSessionContext } from "./rls-db.js";
|
|
23
24
|
export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud.js";
|
|
24
25
|
export { crud as gencowCrud } from "./crud.js";
|
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 {};
|
package/dist/scheduler.js
CHANGED
|
@@ -11,8 +11,9 @@ export function getSchedulerInfo() {
|
|
|
11
11
|
failedJobs: _failedJobs,
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
-
export function createScheduler() {
|
|
14
|
+
export function createScheduler(options) {
|
|
15
15
|
const timers = new Map();
|
|
16
|
+
const isDurable = !!(options?.persistJob && options?.removeJob);
|
|
16
17
|
const cronJobs = new Map();
|
|
17
18
|
const actions = new Map();
|
|
18
19
|
let jobCounter = 0;
|
|
@@ -42,51 +43,75 @@ export function createScheduler() {
|
|
|
42
43
|
await handler(args);
|
|
43
44
|
console.log(`[scheduler] Action "${action}" completed`);
|
|
44
45
|
}
|
|
46
|
+
/** @internal 인메모리 setTimeout 기반 스케줄링 (로컬 dev + durable fallback) */
|
|
47
|
+
function scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry) {
|
|
48
|
+
const timer = setTimeout(async () => {
|
|
49
|
+
jobEntry.status = "running";
|
|
50
|
+
try {
|
|
51
|
+
await executeAction(action, args);
|
|
52
|
+
jobEntry.status = "completed";
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
56
|
+
jobEntry.status = "failed";
|
|
57
|
+
console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
|
|
58
|
+
// 실패 기록 보관 (dead-letter)
|
|
59
|
+
recordFailure(id, action, args, err);
|
|
60
|
+
// onError 콜백 실행 — 다른 action에 에러 정보 전달
|
|
61
|
+
if (scheduleOpts?.onError) {
|
|
62
|
+
try {
|
|
63
|
+
await executeAction(scheduleOpts.onError, {
|
|
64
|
+
failedAction: action,
|
|
65
|
+
failedJobId: id,
|
|
66
|
+
error: err.message,
|
|
67
|
+
originalArgs: args,
|
|
68
|
+
});
|
|
69
|
+
console.log(`[scheduler] onError handler "${scheduleOpts.onError}" completed for "${action}"`);
|
|
70
|
+
}
|
|
71
|
+
catch (onErrorErr) {
|
|
72
|
+
console.error(`[scheduler] onError handler "${scheduleOpts.onError}" also failed:`, onErrorErr);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
timers.delete(id);
|
|
78
|
+
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
81
|
+
if (idx >= 0)
|
|
82
|
+
_pendingJobs.splice(idx, 1);
|
|
83
|
+
}, 60_000); // 1분 후 정리
|
|
84
|
+
}
|
|
85
|
+
}, ms);
|
|
86
|
+
timers.set(id, timer);
|
|
87
|
+
console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`);
|
|
88
|
+
}
|
|
45
89
|
return {
|
|
46
|
-
runAfter(ms, action, args,
|
|
90
|
+
runAfter(ms, action, args, scheduleOpts) {
|
|
47
91
|
const id = generateId();
|
|
48
92
|
const jobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
|
|
49
93
|
_pendingJobs.push(jobEntry);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
console.log(`[scheduler] onError handler "${options.onError}" completed for "${action}"`);
|
|
72
|
-
}
|
|
73
|
-
catch (onErrorErr) {
|
|
74
|
-
console.error(`[scheduler] onError handler "${options.onError}" also failed:`, onErrorErr);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
finally {
|
|
79
|
-
timers.delete(id);
|
|
80
|
-
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
81
|
-
setTimeout(() => {
|
|
82
|
-
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
83
|
-
if (idx >= 0)
|
|
84
|
-
_pendingJobs.splice(idx, 1);
|
|
85
|
-
}, 60_000); // 1분 후 정리
|
|
86
|
-
}
|
|
87
|
-
}, ms);
|
|
88
|
-
timers.set(id, timer);
|
|
89
|
-
console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${options?.onError ? ` [onError: ${options.onError}]` : ""}`);
|
|
94
|
+
// ── Durable mode: DB에 영속화, 실행은 외부 폴러에 위임 ──
|
|
95
|
+
if (isDurable) {
|
|
96
|
+
const runAt = new Date(Date.now() + ms);
|
|
97
|
+
options.persistJob({
|
|
98
|
+
id,
|
|
99
|
+
action,
|
|
100
|
+
args: args ?? {},
|
|
101
|
+
runAt,
|
|
102
|
+
onErrorAction: scheduleOpts?.onError,
|
|
103
|
+
}).then(() => {
|
|
104
|
+
console.log(`[scheduler] Persisted "${action}" to run at ${runAt.toISOString()} (id: ${id}, durable)` +
|
|
105
|
+
`${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`);
|
|
106
|
+
}).catch((err) => {
|
|
107
|
+
console.error(`[scheduler] Failed to persist job ${id}:`, err instanceof Error ? err.message : err);
|
|
108
|
+
// DB persist 실패 → 인메모리 fallback
|
|
109
|
+
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
110
|
+
});
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
// ── 인메모리 mode: 기존 setTimeout 동작 ──
|
|
114
|
+
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
90
115
|
return id;
|
|
91
116
|
},
|
|
92
117
|
runAt(timestamp, action, args, options) {
|
|
@@ -95,6 +120,22 @@ export function createScheduler() {
|
|
|
95
120
|
return this.runAfter(ms, action, args, options);
|
|
96
121
|
},
|
|
97
122
|
cancel(jobId) {
|
|
123
|
+
// ── Durable mode: DB에서도 제거 ──
|
|
124
|
+
if (isDurable) {
|
|
125
|
+
const idx = _pendingJobs.findIndex((j) => j.id === jobId);
|
|
126
|
+
if (idx >= 0) {
|
|
127
|
+
_pendingJobs.splice(idx, 1);
|
|
128
|
+
options.removeJob(jobId).catch((err) => {
|
|
129
|
+
console.error(`[scheduler] Failed to remove persisted job ${jobId}:`, err instanceof Error ? err.message : err);
|
|
130
|
+
});
|
|
131
|
+
console.log(`[scheduler] Cancelled job ${jobId} (durable)`);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
// pendingJobs에 없어도 DB에는 있을 수 있으므로 삭제 시도
|
|
135
|
+
options.removeJob(jobId).catch(() => { });
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// ── 인메모리 mode ──
|
|
98
139
|
const timer = timers.get(jobId);
|
|
99
140
|
if (timer) {
|
|
100
141
|
clearTimeout(timer);
|
package/package.json
CHANGED
|
@@ -236,17 +236,17 @@ describe("crud() + ownerRls — 데이터 격리", () => {
|
|
|
236
236
|
expect(values.userId).toBe("user-A");
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
-
it("create:
|
|
239
|
+
it("create: 타인 user_id 주입 시도는 거부되고 insert까지 가지 않음 (보안)", async () => {
|
|
240
240
|
const mutations = getRegisteredMutations();
|
|
241
241
|
const createDef = mutations.find((m: any) => m.name === "rls_tasks.create");
|
|
242
242
|
|
|
243
243
|
const { ctx, getCapturedValues } = createMockCtx("user-A");
|
|
244
|
-
// 해커가 user_id를 "hacker-id"로 조작 시도
|
|
245
|
-
await
|
|
244
|
+
// 해커가 user_id를 "hacker-id"로 조작 시도 — Layer 1은 즉시 Forbidden (덮어쓰기 전 차단)
|
|
245
|
+
await expect(
|
|
246
|
+
createDef!.handler(ctx, { title: "Spoofed", user_id: "hacker-id" }),
|
|
247
|
+
).rejects.toThrow("Forbidden: cannot create resource for another user");
|
|
246
248
|
|
|
247
|
-
|
|
248
|
-
// 인증된 사용자 ID로 강제 덮어씀 (JS 프로퍼티명)
|
|
249
|
-
expect(values.userId).toBe("user-A");
|
|
249
|
+
expect(getCapturedValues()).toBeNull();
|
|
250
250
|
});
|
|
251
251
|
|
|
252
252
|
// ── update 격리 ──
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
CREATE TABLE "news" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"title" text NOT NULL,
|
|
4
|
+
"user_id" text NOT NULL,
|
|
5
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
6
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
1
9
|
CREATE TABLE "tasks" (
|
|
2
10
|
"id" text PRIMARY KEY NOT NULL,
|
|
3
11
|
"title" text NOT NULL,
|
|
@@ -57,6 +65,7 @@ CREATE TABLE "verification" (
|
|
|
57
65
|
"updated_at" timestamp DEFAULT now()
|
|
58
66
|
);
|
|
59
67
|
--> statement-breakpoint
|
|
68
|
+
ALTER TABLE "news" ADD CONSTRAINT "news_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
60
69
|
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
61
70
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
62
71
|
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|