@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/src/rls-db.ts
CHANGED
|
@@ -1,27 +1,294 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { sql } from "drizzle-orm";
|
|
2
3
|
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* RLS DB wrapper — execution paths for `withRlsConnection`:
|
|
7
|
+
* 1. **Reuse outer Drizzle transaction** (`reuseOuterConnection`): same connection, apply GUCs then run `fn`.
|
|
8
|
+
* 2. **Bun SQL** (`session.client.begin`): short transaction around `fn`.
|
|
9
|
+
* 3. **PGlite / drivers with `transaction()`**: delegate to driver helper.
|
|
10
|
+
* 4. **node-pg Pool** (`connect` + `query`): lease a client, BEGIN → set_config → `fn` → COMMIT, or ROLLBACK on error, then `release()`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Values pushed into PostgreSQL session GUCs (`set_config(..., true)`) for RLS / tenant checks.
|
|
15
|
+
* Optional fields become `app.*` settings; add arbitrary allowed keys via `vars`.
|
|
16
|
+
*/
|
|
17
|
+
export type RlsSessionContext = {
|
|
18
|
+
/** `app.current_user_id` — required by `ownerRls()` policies in this repo. */
|
|
19
|
+
userId: string;
|
|
20
|
+
/** When set: `app.current_user_role`. */
|
|
21
|
+
role?: string;
|
|
22
|
+
/** When set: `app.tenant_id`. */
|
|
23
|
+
tenantId?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Extra transaction-local settings: keys must be validated `app.*` GUC names
|
|
26
|
+
* and must not duplicate `app.current_user_id` / `app.current_user_role` / `app.tenant_id`
|
|
27
|
+
* (use the top-level fields for those).
|
|
28
|
+
*/
|
|
29
|
+
vars?: Record<string, string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const gucNameRe = /^app\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
|
|
33
|
+
|
|
34
|
+
const RESERVED_VARS_KEYS = new Set([
|
|
35
|
+
"app.current_user_id",
|
|
36
|
+
"app.current_user_role",
|
|
37
|
+
"app.tenant_id",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function assertSafeGucName(key: string): void {
|
|
41
|
+
if (!gucNameRe.test(key)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`createRlsDb: GUC name "${key}" is invalid — use lowercase app.* names (e.g. app.org_id)`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ordered `(name, value)` pairs for `set_config(name, value, true)`. */
|
|
49
|
+
function rlsSetConfigPairs(rls: RlsSessionContext): [string, string][] {
|
|
50
|
+
const pairs: [string, string][] = [["app.current_user_id", rls.userId]];
|
|
51
|
+
if (rls.role !== undefined) {
|
|
52
|
+
pairs.push(["app.current_user_role", rls.role]);
|
|
53
|
+
}
|
|
54
|
+
if (rls.tenantId !== undefined) {
|
|
55
|
+
pairs.push(["app.tenant_id", rls.tenantId]);
|
|
56
|
+
}
|
|
57
|
+
if (rls.vars) {
|
|
58
|
+
for (const [key, value] of Object.entries(rls.vars)) {
|
|
59
|
+
assertSafeGucName(key);
|
|
60
|
+
if (RESERVED_VARS_KEYS.has(key)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`createRlsDb: vars must not set "${key}" — use userId, role, or tenantId on the context object`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
pairs.push([key, value]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return pairs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function forEachSetConfig(
|
|
72
|
+
rls: RlsSessionContext,
|
|
73
|
+
setOne: (name: string, value: string) => Promise<void>,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
for (const [name, value] of rlsSetConfigPairs(rls)) {
|
|
76
|
+
await setOne(name, value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* While a prepared query runs, nested queries reuse this client (same outer `begin` / tx).
|
|
82
|
+
*/
|
|
83
|
+
const rlsExecClient = new AsyncLocalStorage<unknown>();
|
|
84
|
+
|
|
85
|
+
function isDrizzleTransactionDb(db: unknown): boolean {
|
|
86
|
+
const d = db as { constructor?: { name?: string }; rollback?: unknown };
|
|
87
|
+
if (typeof d?.rollback === "function") {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const name = d?.constructor?.name;
|
|
91
|
+
return typeof name === "string" && name.includes("Transaction");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function execSetConfig(client: any, name: string, value: string): Promise<void> {
|
|
95
|
+
if (typeof client?.unsafe === "function") {
|
|
96
|
+
await client.unsafe(`select set_config($1::text, $2::text, true)`, [name, value]);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (typeof client?.query === "function") {
|
|
100
|
+
await client.query(`select set_config($1::text, $2::text, true)`, [name, value]);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw new Error(
|
|
104
|
+
"createRlsDb: unsupported SQL driver (expected Bun SQL, node-pg client/pool, or PGlite)",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function applyRlsSessionVars(client: any, rls: RlsSessionContext): Promise<void> {
|
|
109
|
+
await forEachSetConfig(rls, (name, value) => execSetConfig(client, name, value));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function injectRlsVarsOnTx(tx: any, rls: RlsSessionContext): Promise<void> {
|
|
113
|
+
await forEachSetConfig(rls, (name, value) =>
|
|
114
|
+
tx.execute(sql`SELECT set_config(${name}, ${value}, true)`),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* pg `Pool.connect()`-style client: BEGIN → apply RLS GUCs → `fn` → COMMIT on success,
|
|
120
|
+
* or ROLLBACK if anything fails (including failed COMMIT). Always `release()` in `finally`.
|
|
121
|
+
*
|
|
122
|
+
* @internal Exported only for unit tests — not re-exported from `@gencow/core` public API.
|
|
123
|
+
*/
|
|
124
|
+
export async function withRlsLeasedConnection<T>(
|
|
125
|
+
leased: { query: (sql: string) => Promise<unknown>; release: () => void },
|
|
126
|
+
rls: RlsSessionContext,
|
|
127
|
+
fn: (client: { query: (sql: string) => Promise<unknown>; release: () => void }) => Promise<T>,
|
|
128
|
+
): Promise<T> {
|
|
129
|
+
try {
|
|
130
|
+
await leased.query("begin");
|
|
131
|
+
await applyRlsSessionVars(leased, rls);
|
|
132
|
+
const result = await rlsExecClient.run(leased, () => fn(leased));
|
|
133
|
+
await leased.query("commit");
|
|
134
|
+
return result;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
try {
|
|
137
|
+
await leased.query("rollback");
|
|
138
|
+
} catch {
|
|
139
|
+
/* ignore */
|
|
140
|
+
}
|
|
141
|
+
throw e;
|
|
142
|
+
} finally {
|
|
143
|
+
leased.release();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Runs `fn` with a connection that has transaction-local RLS session variables set.
|
|
149
|
+
* Pool / autocommit: opens a short transaction (Bun `begin`, PGlite `transaction`).
|
|
150
|
+
* When `db` is already a Drizzle transaction object, reuses that connection (no nested top-level tx).
|
|
151
|
+
*/
|
|
152
|
+
async function withRlsConnection<T>(
|
|
153
|
+
session: any,
|
|
154
|
+
rls: RlsSessionContext,
|
|
155
|
+
reuseOuterConnection: boolean,
|
|
156
|
+
fn: (client: any) => Promise<T>,
|
|
157
|
+
): Promise<T> {
|
|
158
|
+
if (reuseOuterConnection) {
|
|
159
|
+
const c = session.client;
|
|
160
|
+
return rlsExecClient.run(c, async () => {
|
|
161
|
+
await applyRlsSessionVars(c, rls);
|
|
162
|
+
return fn(c);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const c = session.client;
|
|
166
|
+
const runInner = async (client: any) => {
|
|
167
|
+
await applyRlsSessionVars(client, rls);
|
|
168
|
+
return rlsExecClient.run(client, () => fn(client));
|
|
169
|
+
};
|
|
170
|
+
if (typeof c.begin === "function") {
|
|
171
|
+
return c.begin(runInner);
|
|
172
|
+
}
|
|
173
|
+
if (typeof c.transaction === "function") {
|
|
174
|
+
return c.transaction(runInner);
|
|
175
|
+
}
|
|
176
|
+
if (typeof c.connect === "function" && typeof c.query === "function") {
|
|
177
|
+
const leased = await c.connect();
|
|
178
|
+
return withRlsLeasedConnection(leased, rls, fn);
|
|
179
|
+
}
|
|
180
|
+
return runInner(c);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function wrapPreparedQuery(
|
|
184
|
+
pq: any,
|
|
185
|
+
session: any,
|
|
186
|
+
rls: RlsSessionContext,
|
|
187
|
+
reuseOuterConnection: boolean,
|
|
188
|
+
) {
|
|
189
|
+
const origExecute = pq.execute.bind(pq);
|
|
190
|
+
const origAll = pq.all.bind(pq);
|
|
191
|
+
|
|
192
|
+
pq.execute = async (placeholderValues?: unknown) => {
|
|
193
|
+
const active = rlsExecClient.getStore();
|
|
194
|
+
if (active) {
|
|
195
|
+
const prev = pq.client;
|
|
196
|
+
pq.client = active;
|
|
197
|
+
try {
|
|
198
|
+
return await origExecute(placeholderValues);
|
|
199
|
+
} finally {
|
|
200
|
+
pq.client = prev;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return withRlsConnection(session, rls, reuseOuterConnection, async (client) => {
|
|
204
|
+
const prev = pq.client;
|
|
205
|
+
pq.client = client;
|
|
206
|
+
try {
|
|
207
|
+
return await origExecute(placeholderValues);
|
|
208
|
+
} finally {
|
|
209
|
+
pq.client = prev;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
pq.all = async (placeholderValues?: unknown) => {
|
|
215
|
+
const active = rlsExecClient.getStore();
|
|
216
|
+
if (active) {
|
|
217
|
+
const prev = pq.client;
|
|
218
|
+
pq.client = active;
|
|
219
|
+
try {
|
|
220
|
+
return await origAll(placeholderValues);
|
|
221
|
+
} finally {
|
|
222
|
+
pq.client = prev;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return withRlsConnection(session, rls, reuseOuterConnection, async (client) => {
|
|
226
|
+
const prev = pq.client;
|
|
227
|
+
pq.client = client;
|
|
228
|
+
try {
|
|
229
|
+
return await origAll(placeholderValues);
|
|
230
|
+
} finally {
|
|
231
|
+
pq.client = prev;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function wrapSession(session: any, rls: RlsSessionContext, reuseOuterConnection: boolean) {
|
|
238
|
+
return new Proxy(session, {
|
|
239
|
+
get(sTarget, sProp, sRecv) {
|
|
240
|
+
if (sProp === "prepareQuery") {
|
|
241
|
+
return (...args: unknown[]) => {
|
|
242
|
+
const pq = sTarget.prepareQuery(...args);
|
|
243
|
+
wrapPreparedQuery(pq, sTarget, rls, reuseOuterConnection);
|
|
244
|
+
return pq;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const v = Reflect.get(sTarget, sProp, sRecv);
|
|
248
|
+
return typeof v === "function" ? v.bind(sTarget) : v;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* RLS-scoped Drizzle handle: every query path (`select`, `insert`, `execute`, …) runs
|
|
255
|
+
* `set_config` for the given context in the same DB transaction as the query
|
|
256
|
+
* (PgBouncer–safe transaction-local GUCs).
|
|
7
257
|
*
|
|
8
|
-
* `
|
|
258
|
+
* `db.transaction()` still injects the same variables at the start of the callback transaction.
|
|
9
259
|
*/
|
|
10
|
-
export function createRlsDb(
|
|
260
|
+
export function createRlsDb(
|
|
261
|
+
db: PgDatabase<any, any, any>,
|
|
262
|
+
rls: RlsSessionContext,
|
|
263
|
+
): PgDatabase<any, any, any> {
|
|
264
|
+
const reuseOuterConnection = isDrizzleTransactionDb(db);
|
|
265
|
+
const baseSession = (db as unknown as { session: any }).session;
|
|
266
|
+
const wrappedSession = wrapSession(baseSession, rls, reuseOuterConnection);
|
|
267
|
+
|
|
11
268
|
return new Proxy(db, {
|
|
12
269
|
get(target, prop, receiver) {
|
|
270
|
+
if (prop === "session") {
|
|
271
|
+
return wrappedSession;
|
|
272
|
+
}
|
|
273
|
+
if (prop === "_") {
|
|
274
|
+
const inner = target._;
|
|
275
|
+
return new Proxy(inner, {
|
|
276
|
+
get(i, p, r) {
|
|
277
|
+
if (p === "session") return wrappedSession;
|
|
278
|
+
return Reflect.get(i, p, r);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
13
282
|
if (prop === "transaction") {
|
|
14
283
|
return async (callback: any, ...rest: any[]) => {
|
|
15
|
-
return await target.transaction(async (tx) => {
|
|
16
|
-
await tx
|
|
17
|
-
sql`SELECT set_config('app.current_user_id', ${userId}, true)`
|
|
18
|
-
);
|
|
284
|
+
return await target.transaction(async (tx: any) => {
|
|
285
|
+
await injectRlsVarsOnTx(tx, rls);
|
|
19
286
|
return await callback(tx);
|
|
20
287
|
}, ...rest as any);
|
|
21
288
|
};
|
|
22
289
|
}
|
|
23
290
|
const value = Reflect.get(target, prop, receiver);
|
|
24
|
-
return typeof value === "function" ? value.bind(
|
|
25
|
-
}
|
|
291
|
+
return typeof value === "function" ? value.bind(receiver) : value;
|
|
292
|
+
},
|
|
26
293
|
});
|
|
27
294
|
}
|
package/src/rls.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function registerOwnerRls(table: PgTable, meta: OwnerRlsMeta): void {
|
|
|
43
43
|
* 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
|
|
44
44
|
* PGlite + PostgreSQL 양쪽에서 동작.
|
|
45
45
|
* - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
|
|
46
|
-
* createRlsDb()
|
|
46
|
+
* createRlsDb()가 RlsSessionContext로 set_config 주입.
|
|
47
47
|
*
|
|
48
48
|
* @example
|
|
49
49
|
* ```ts
|
package/src/scheduler.ts
CHANGED
|
@@ -10,6 +10,30 @@ export interface ScheduleOptions {
|
|
|
10
10
|
onError?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** scheduled job의 DB 영속화를 위한 콜백 */
|
|
14
|
+
export interface ScheduledJobRecord {
|
|
15
|
+
id: string;
|
|
16
|
+
action: string;
|
|
17
|
+
args: unknown;
|
|
18
|
+
runAt: Date;
|
|
19
|
+
onErrorAction?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* createScheduler 옵션.
|
|
24
|
+
*
|
|
25
|
+
* persistJob/removeJob이 주어지면 runAfter()가 setTimeout 대신 DB에 영속화.
|
|
26
|
+
* 외부 폴러(Platform Cron Runner)가 주기적으로 due된 job을 조회하여 실행.
|
|
27
|
+
*
|
|
28
|
+
* 콜백 미설정 시 기존 인메모리 setTimeout 동작 유지 (로컬 dev).
|
|
29
|
+
*/
|
|
30
|
+
export interface CreateSchedulerOptions {
|
|
31
|
+
/** Job을 DB에 영속화 (INSERT) */
|
|
32
|
+
persistJob?: (job: ScheduledJobRecord) => Promise<void>;
|
|
33
|
+
/** Job을 DB에서 제거 (DELETE) — cancel/완료 시 */
|
|
34
|
+
removeJob?: (jobId: string) => Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
13
37
|
/** 실패한 작업의 기록 */
|
|
14
38
|
export interface FailedJob {
|
|
15
39
|
id: string;
|
|
@@ -39,18 +63,26 @@ export interface Scheduler {
|
|
|
39
63
|
/**
|
|
40
64
|
* Create a scheduler instance — Convex scheduler 패턴 재현
|
|
41
65
|
*
|
|
66
|
+
* @param options persistJob/removeJob 콜백을 전달하면 durable mode 활성화.
|
|
67
|
+
* BaaS 모드에서는 서버가 DB 콜백을 전달하여 sleep-safe 보장.
|
|
68
|
+
* 로컬 dev에서는 콜백 없이 호출하여 기존 setTimeout 동작.
|
|
69
|
+
*
|
|
42
70
|
* @example
|
|
71
|
+
* // 로컬 dev (인메모리)
|
|
43
72
|
* const scheduler = createScheduler();
|
|
44
73
|
*
|
|
74
|
+
* // BaaS (DB-backed durable)
|
|
75
|
+
* const scheduler = createScheduler({
|
|
76
|
+
* persistJob: async (job) => { await rawSql('INSERT INTO ...', ...) },
|
|
77
|
+
* removeJob: async (id) => { await rawSql('DELETE FROM ...', [id]) },
|
|
78
|
+
* });
|
|
79
|
+
*
|
|
45
80
|
* // Register actions
|
|
46
81
|
* scheduler.registerAction('emails.send', async (args) => { ... });
|
|
47
82
|
*
|
|
48
|
-
* // Schedule (Convex-style)
|
|
83
|
+
* // Schedule (Convex-style) — durable mode에서는 DB에 영속화
|
|
49
84
|
* scheduler.runAfter(5 * 60 * 1000, 'emails.send', { to: 'user@test.com' });
|
|
50
85
|
*
|
|
51
|
-
* // Schedule with error callback (dead-letter 패턴)
|
|
52
|
-
* scheduler.runAfter(0, 'pipeline.step2', args, { onError: 'pipeline.onStepError' });
|
|
53
|
-
*
|
|
54
86
|
* // Cron (Convex-style)
|
|
55
87
|
* scheduler.cron('daily-cleanup', '0 2 * * *', async () => { ... });
|
|
56
88
|
*/
|
|
@@ -84,8 +116,9 @@ export function getSchedulerInfo() {
|
|
|
84
116
|
};
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
export function createScheduler(): Scheduler {
|
|
119
|
+
export function createScheduler(options?: CreateSchedulerOptions): Scheduler {
|
|
88
120
|
const timers = new Map<string, NodeJS.Timeout>();
|
|
121
|
+
const isDurable = !!(options?.persistJob && options?.removeJob);
|
|
89
122
|
const cronJobs = new Map<string, cron.ScheduledTask>();
|
|
90
123
|
const actions = new Map<string, ActionHandler>();
|
|
91
124
|
|
|
@@ -120,52 +153,80 @@ export function createScheduler(): Scheduler {
|
|
|
120
153
|
console.log(`[scheduler] Action "${action}" completed`);
|
|
121
154
|
}
|
|
122
155
|
|
|
156
|
+
/** @internal 인메모리 setTimeout 기반 스케줄링 (로컬 dev + durable fallback) */
|
|
157
|
+
function scheduleInMemory(id: string, ms: number, action: string, args: any, scheduleOpts: ScheduleOptions | undefined, jobEntry: PendingJobEntry): void {
|
|
158
|
+
const timer = setTimeout(async () => {
|
|
159
|
+
jobEntry.status = "running";
|
|
160
|
+
try {
|
|
161
|
+
await executeAction(action, args);
|
|
162
|
+
jobEntry.status = "completed";
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
165
|
+
jobEntry.status = "failed";
|
|
166
|
+
console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
|
|
167
|
+
|
|
168
|
+
// 실패 기록 보관 (dead-letter)
|
|
169
|
+
recordFailure(id, action, args, err);
|
|
170
|
+
|
|
171
|
+
// onError 콜백 실행 — 다른 action에 에러 정보 전달
|
|
172
|
+
if (scheduleOpts?.onError) {
|
|
173
|
+
try {
|
|
174
|
+
await executeAction(scheduleOpts.onError, {
|
|
175
|
+
failedAction: action,
|
|
176
|
+
failedJobId: id,
|
|
177
|
+
error: err.message,
|
|
178
|
+
originalArgs: args,
|
|
179
|
+
});
|
|
180
|
+
console.log(`[scheduler] onError handler "${scheduleOpts.onError}" completed for "${action}"`);
|
|
181
|
+
} catch (onErrorErr) {
|
|
182
|
+
console.error(`[scheduler] onError handler "${scheduleOpts.onError}" also failed:`, onErrorErr);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} finally {
|
|
186
|
+
timers.delete(id);
|
|
187
|
+
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
190
|
+
if (idx >= 0) _pendingJobs.splice(idx, 1);
|
|
191
|
+
}, 60_000); // 1분 후 정리
|
|
192
|
+
}
|
|
193
|
+
}, ms);
|
|
194
|
+
timers.set(id, timer);
|
|
195
|
+
console.log(
|
|
196
|
+
`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
123
200
|
return {
|
|
124
|
-
runAfter(ms: number, action: string, args?: any,
|
|
201
|
+
runAfter(ms: number, action: string, args?: any, scheduleOpts?: ScheduleOptions): string {
|
|
125
202
|
const id = generateId();
|
|
126
203
|
const jobEntry: PendingJobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
|
|
127
204
|
_pendingJobs.push(jobEntry);
|
|
128
205
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
console.error(`[scheduler] onError handler "${options.onError}" also failed:`, onErrorErr);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
} finally {
|
|
157
|
-
timers.delete(id);
|
|
158
|
-
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
159
|
-
setTimeout(() => {
|
|
160
|
-
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
161
|
-
if (idx >= 0) _pendingJobs.splice(idx, 1);
|
|
162
|
-
}, 60_000); // 1분 후 정리
|
|
163
|
-
}
|
|
164
|
-
}, ms);
|
|
165
|
-
timers.set(id, timer);
|
|
166
|
-
console.log(
|
|
167
|
-
`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${options?.onError ? ` [onError: ${options.onError}]` : ""}`
|
|
168
|
-
);
|
|
206
|
+
// ── Durable mode: DB에 영속화, 실행은 외부 폴러에 위임 ──
|
|
207
|
+
if (isDurable) {
|
|
208
|
+
const runAt = new Date(Date.now() + ms);
|
|
209
|
+
options!.persistJob!({
|
|
210
|
+
id,
|
|
211
|
+
action,
|
|
212
|
+
args: args ?? {},
|
|
213
|
+
runAt,
|
|
214
|
+
onErrorAction: scheduleOpts?.onError,
|
|
215
|
+
}).then(() => {
|
|
216
|
+
console.log(
|
|
217
|
+
`[scheduler] Persisted "${action}" to run at ${runAt.toISOString()} (id: ${id}, durable)` +
|
|
218
|
+
`${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`
|
|
219
|
+
);
|
|
220
|
+
}).catch((err) => {
|
|
221
|
+
console.error(`[scheduler] Failed to persist job ${id}:`, err instanceof Error ? err.message : err);
|
|
222
|
+
// DB persist 실패 → 인메모리 fallback
|
|
223
|
+
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
224
|
+
});
|
|
225
|
+
return id;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 인메모리 mode: 기존 setTimeout 동작 ──
|
|
229
|
+
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
169
230
|
return id;
|
|
170
231
|
},
|
|
171
232
|
|
|
@@ -177,6 +238,23 @@ export function createScheduler(): Scheduler {
|
|
|
177
238
|
},
|
|
178
239
|
|
|
179
240
|
cancel(jobId: string): boolean {
|
|
241
|
+
// ── Durable mode: DB에서도 제거 ──
|
|
242
|
+
if (isDurable) {
|
|
243
|
+
const idx = _pendingJobs.findIndex((j) => j.id === jobId);
|
|
244
|
+
if (idx >= 0) {
|
|
245
|
+
_pendingJobs.splice(idx, 1);
|
|
246
|
+
options!.removeJob!(jobId).catch((err) => {
|
|
247
|
+
console.error(`[scheduler] Failed to remove persisted job ${jobId}:`, err instanceof Error ? err.message : err);
|
|
248
|
+
});
|
|
249
|
+
console.log(`[scheduler] Cancelled job ${jobId} (durable)`);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
// pendingJobs에 없어도 DB에는 있을 수 있으므로 삭제 시도
|
|
253
|
+
options!.removeJob!(jobId).catch(() => {});
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── 인메모리 mode ──
|
|
180
258
|
const timer = timers.get(jobId);
|
|
181
259
|
if (timer) {
|
|
182
260
|
clearTimeout(timer);
|
package/src/server.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db";
|
|
9
|
-
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
-
export type { StorageImageTierConfig } from "./storage";
|
|
11
|
-
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
12
|
-
export { authMiddleware, authRoutes, getUsers } from "./auth";
|
|
8
|
+
export { createDb } from "./db.js";
|
|
9
|
+
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
|
+
export type { StorageImageTierConfig } from "./storage.js";
|
|
11
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
12
|
+
export { authMiddleware, authRoutes, getUsers } from "./auth.js";
|