@gencow/core 0.1.23 → 0.1.25
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 +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -1
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow-types.d.ts +81 -0
- package/dist/workflow-types.js +12 -0
- package/dist/workflow.d.ts +30 -0
- package/dist/workflow.js +150 -0
- package/dist/workflows-api.d.ts +13 -0
- package/dist/workflows-api.js +321 -0
- package/package.json +46 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -114
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +50 -44
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +606 -0
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +71 -6
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +108 -0
- package/src/workflow.ts +188 -0
- package/src/workflows-api.ts +415 -0
- package/src/db.ts +0 -18
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { mutation, query } from "./reactive.js";
|
|
3
|
+
import { createWorkflowRealtimeToken, deserializeWorkflowValue, getWorkflowResumeActionName, getWorkflowRealtimeKey, serializeWorkflowValue, } from "./workflow.js";
|
|
4
|
+
import { GencowValidationError, v } from "./v.js";
|
|
5
|
+
import { deriveWorkflowStatus } from "./workflow-types.js";
|
|
6
|
+
const WORKFLOW_STATUSES = new Set(["pending", "running", "completed", "failed"]);
|
|
7
|
+
const WORKFLOW_DERIVED_PENDING_STATUSES = new Set(["queued", "waiting", "sleeping"]);
|
|
8
|
+
function rowsFromResult(result) {
|
|
9
|
+
if (Array.isArray(result))
|
|
10
|
+
return result;
|
|
11
|
+
if (result && typeof result === "object" && Array.isArray(result.rows)) {
|
|
12
|
+
return result.rows;
|
|
13
|
+
}
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
function parseJsonField(value) {
|
|
17
|
+
if (typeof value !== "string")
|
|
18
|
+
return value;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function toIsoString(value) {
|
|
27
|
+
if (value instanceof Date)
|
|
28
|
+
return value.toISOString();
|
|
29
|
+
const parsed = new Date(value);
|
|
30
|
+
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : String(value);
|
|
31
|
+
}
|
|
32
|
+
function toOptionalIsoString(value) {
|
|
33
|
+
return value ? toIsoString(value) : null;
|
|
34
|
+
}
|
|
35
|
+
function mapWorkflowSummary(row) {
|
|
36
|
+
return {
|
|
37
|
+
id: row.id,
|
|
38
|
+
name: row.name,
|
|
39
|
+
status: row.status,
|
|
40
|
+
derivedStatus: deriveWorkflowStatus(row.status, row.current_step),
|
|
41
|
+
currentStep: row.current_step,
|
|
42
|
+
error: row.error,
|
|
43
|
+
retryCount: row.retry_count,
|
|
44
|
+
maxRetries: row.max_retries,
|
|
45
|
+
maxDurationMs: Number(row.max_duration_ms),
|
|
46
|
+
startedAt: toIsoString(row.started_at),
|
|
47
|
+
updatedAt: toIsoString(row.updated_at),
|
|
48
|
+
completedAt: toOptionalIsoString(row.completed_at),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function mapWorkflowStep(row) {
|
|
52
|
+
return {
|
|
53
|
+
name: row.step_name,
|
|
54
|
+
status: row.status,
|
|
55
|
+
output: deserializeWorkflowValue(parseJsonField(row.output)),
|
|
56
|
+
error: row.error,
|
|
57
|
+
startedAt: toOptionalIsoString(row.started_at),
|
|
58
|
+
updatedAt: toIsoString(row.updated_at),
|
|
59
|
+
completedAt: toOptionalIsoString(row.completed_at),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function normalizeListLimit(limit) {
|
|
63
|
+
if (limit == null)
|
|
64
|
+
return 20;
|
|
65
|
+
if (!Number.isFinite(limit)) {
|
|
66
|
+
throw new GencowValidationError(`Argument "limit": expected a finite number, got ${limit}`);
|
|
67
|
+
}
|
|
68
|
+
return Math.max(1, Math.min(100, Math.floor(limit)));
|
|
69
|
+
}
|
|
70
|
+
function normalizeStatus(status) {
|
|
71
|
+
if (status == null)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (!WORKFLOW_STATUSES.has(status)) {
|
|
74
|
+
throw new GencowValidationError(`Argument "status": expected one of pending, running, completed, failed`);
|
|
75
|
+
}
|
|
76
|
+
return status;
|
|
77
|
+
}
|
|
78
|
+
function normalizeDerivedStatus(status) {
|
|
79
|
+
if (status == null)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (WORKFLOW_DERIVED_PENDING_STATUSES.has(status)) {
|
|
82
|
+
return status;
|
|
83
|
+
}
|
|
84
|
+
return normalizeStatus(status);
|
|
85
|
+
}
|
|
86
|
+
function toWorkflowStatusFilter(status) {
|
|
87
|
+
if (status == null)
|
|
88
|
+
return undefined;
|
|
89
|
+
if (WORKFLOW_DERIVED_PENDING_STATUSES.has(status)) {
|
|
90
|
+
return "pending";
|
|
91
|
+
}
|
|
92
|
+
return status;
|
|
93
|
+
}
|
|
94
|
+
async function ensureWorkflowRealtimeToken(db, workflowId, currentToken) {
|
|
95
|
+
if (currentToken && currentToken.trim() !== "")
|
|
96
|
+
return currentToken;
|
|
97
|
+
const nextToken = createWorkflowRealtimeToken();
|
|
98
|
+
const updateResult = await db.execute(sql `
|
|
99
|
+
UPDATE _gencow_workflows
|
|
100
|
+
SET realtime_token = ${nextToken}
|
|
101
|
+
WHERE id = ${workflowId}
|
|
102
|
+
AND (realtime_token IS NULL OR realtime_token = '')
|
|
103
|
+
RETURNING realtime_token
|
|
104
|
+
`);
|
|
105
|
+
const updatedToken = rowsFromResult(updateResult)[0]?.realtime_token ?? null;
|
|
106
|
+
if (updatedToken && updatedToken.trim() !== "")
|
|
107
|
+
return updatedToken;
|
|
108
|
+
const rereadResult = await db.execute(sql `
|
|
109
|
+
SELECT realtime_token
|
|
110
|
+
FROM _gencow_workflows
|
|
111
|
+
WHERE id = ${workflowId}
|
|
112
|
+
LIMIT 1
|
|
113
|
+
`);
|
|
114
|
+
const rereadToken = rowsFromResult(rereadResult)[0]?.realtime_token ?? null;
|
|
115
|
+
return rereadToken && rereadToken.trim() !== "" ? rereadToken : null;
|
|
116
|
+
}
|
|
117
|
+
async function loadWorkflowSignalTarget(db, workflowId) {
|
|
118
|
+
const result = await db.execute(sql `
|
|
119
|
+
SELECT
|
|
120
|
+
id,
|
|
121
|
+
name,
|
|
122
|
+
status,
|
|
123
|
+
current_step,
|
|
124
|
+
user_id
|
|
125
|
+
FROM _gencow_workflows
|
|
126
|
+
WHERE id = ${workflowId}
|
|
127
|
+
LIMIT 1
|
|
128
|
+
`);
|
|
129
|
+
return rowsFromResult(result)[0] ?? null;
|
|
130
|
+
}
|
|
131
|
+
export async function loadWorkflowSnapshot(db, workflowId, options) {
|
|
132
|
+
const workflowResult = await db.execute(sql `
|
|
133
|
+
SELECT
|
|
134
|
+
id,
|
|
135
|
+
name,
|
|
136
|
+
args,
|
|
137
|
+
status,
|
|
138
|
+
current_step,
|
|
139
|
+
result,
|
|
140
|
+
error,
|
|
141
|
+
retry_count,
|
|
142
|
+
max_retries,
|
|
143
|
+
max_duration_ms,
|
|
144
|
+
started_at,
|
|
145
|
+
updated_at,
|
|
146
|
+
completed_at,
|
|
147
|
+
realtime_token,
|
|
148
|
+
user_id
|
|
149
|
+
FROM _gencow_workflows
|
|
150
|
+
WHERE id = ${workflowId}
|
|
151
|
+
LIMIT 1
|
|
152
|
+
`);
|
|
153
|
+
const row = rowsFromResult(workflowResult)[0] ?? null;
|
|
154
|
+
if (!row)
|
|
155
|
+
return null;
|
|
156
|
+
const viewerUserId = options?.viewerUserId ?? null;
|
|
157
|
+
if (options?.requireViewerMatch && row.user_id && row.user_id !== viewerUserId) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const realtimeToken = await ensureWorkflowRealtimeToken(db, workflowId, row.realtime_token);
|
|
161
|
+
if (!realtimeToken)
|
|
162
|
+
return null;
|
|
163
|
+
const stepsResult = await db.execute(sql `
|
|
164
|
+
SELECT
|
|
165
|
+
step_name,
|
|
166
|
+
status,
|
|
167
|
+
output,
|
|
168
|
+
error,
|
|
169
|
+
started_at,
|
|
170
|
+
updated_at,
|
|
171
|
+
completed_at
|
|
172
|
+
FROM _gencow_workflow_steps
|
|
173
|
+
WHERE workflow_id = ${workflowId}
|
|
174
|
+
ORDER BY COALESCE(started_at, updated_at) ASC, step_name ASC
|
|
175
|
+
`);
|
|
176
|
+
return {
|
|
177
|
+
...mapWorkflowSummary(row),
|
|
178
|
+
args: deserializeWorkflowValue(parseJsonField(row.args)),
|
|
179
|
+
result: deserializeWorkflowValue(parseJsonField(row.result)),
|
|
180
|
+
steps: rowsFromResult(stepsResult).map(mapWorkflowStep),
|
|
181
|
+
realtimeKey: getWorkflowRealtimeKey(row.id, realtimeToken),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function registerWorkflowsApi() {
|
|
185
|
+
if (globalThis.__gencow_workflowsApiRegistered)
|
|
186
|
+
return;
|
|
187
|
+
globalThis.__gencow_workflowsApiRegistered = true;
|
|
188
|
+
query("workflows.get", {
|
|
189
|
+
args: { id: v.string() },
|
|
190
|
+
public: true,
|
|
191
|
+
handler: async (ctx, args) => {
|
|
192
|
+
return loadWorkflowSnapshot(ctx.unsafeDb, args.id, {
|
|
193
|
+
viewerUserId: ctx.auth.getUserIdentity()?.id ?? null,
|
|
194
|
+
requireViewerMatch: true,
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
mutation("workflows.signal", {
|
|
199
|
+
args: {
|
|
200
|
+
id: v.string(),
|
|
201
|
+
event: v.string(),
|
|
202
|
+
payload: v.optional(v.any()),
|
|
203
|
+
},
|
|
204
|
+
public: true,
|
|
205
|
+
handler: async (ctx, args) => {
|
|
206
|
+
const normalizedEvent = args.event.trim();
|
|
207
|
+
if (!normalizedEvent) {
|
|
208
|
+
throw new GencowValidationError(`Argument "event": expected a non-empty string`);
|
|
209
|
+
}
|
|
210
|
+
const workflow = await loadWorkflowSignalTarget(ctx.unsafeDb, args.id);
|
|
211
|
+
const viewerUserId = ctx.auth.getUserIdentity()?.id ?? null;
|
|
212
|
+
if (!workflow || (workflow.user_id && workflow.user_id !== viewerUserId)) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
workflowId: args.id,
|
|
216
|
+
event: normalizedEvent,
|
|
217
|
+
scheduledJobId: null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (workflow.status === "completed" || workflow.status === "failed") {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
workflowId: workflow.id,
|
|
224
|
+
event: normalizedEvent,
|
|
225
|
+
scheduledJobId: null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const persistedPayload = serializeWorkflowValue(args.payload);
|
|
229
|
+
await ctx.unsafeDb.execute(sql `
|
|
230
|
+
INSERT INTO _gencow_workflow_events (
|
|
231
|
+
id,
|
|
232
|
+
workflow_id,
|
|
233
|
+
event_name,
|
|
234
|
+
payload
|
|
235
|
+
)
|
|
236
|
+
VALUES (
|
|
237
|
+
${crypto.randomUUID()},
|
|
238
|
+
${workflow.id},
|
|
239
|
+
${normalizedEvent},
|
|
240
|
+
${JSON.stringify(persistedPayload)}::jsonb
|
|
241
|
+
)
|
|
242
|
+
`);
|
|
243
|
+
let scheduledJobId = null;
|
|
244
|
+
if (workflow.status === "pending" && workflow.current_step?.startsWith("wait:")) {
|
|
245
|
+
try {
|
|
246
|
+
scheduledJobId = ctx.scheduler.runAfter(0, getWorkflowResumeActionName(workflow.name), {
|
|
247
|
+
workflowId: workflow.id,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
scheduledJobId = null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
ok: true,
|
|
256
|
+
workflowId: workflow.id,
|
|
257
|
+
event: normalizedEvent,
|
|
258
|
+
scheduledJobId,
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
query("workflows.list", {
|
|
263
|
+
args: {
|
|
264
|
+
limit: v.optional(v.number()),
|
|
265
|
+
status: v.optional(v.string()),
|
|
266
|
+
},
|
|
267
|
+
handler: async (ctx, args) => {
|
|
268
|
+
const userId = ctx.auth.requireAuth().id;
|
|
269
|
+
const limit = normalizeListLimit(args.limit);
|
|
270
|
+
const requestedStatus = normalizeDerivedStatus(args.status);
|
|
271
|
+
const status = toWorkflowStatusFilter(requestedStatus);
|
|
272
|
+
const result = status == null
|
|
273
|
+
? await ctx.unsafeDb.execute(sql `
|
|
274
|
+
SELECT
|
|
275
|
+
id,
|
|
276
|
+
name,
|
|
277
|
+
args,
|
|
278
|
+
status,
|
|
279
|
+
current_step,
|
|
280
|
+
result,
|
|
281
|
+
error,
|
|
282
|
+
retry_count,
|
|
283
|
+
max_retries,
|
|
284
|
+
max_duration_ms,
|
|
285
|
+
started_at,
|
|
286
|
+
updated_at,
|
|
287
|
+
completed_at,
|
|
288
|
+
user_id
|
|
289
|
+
FROM _gencow_workflows
|
|
290
|
+
WHERE user_id = ${userId}
|
|
291
|
+
ORDER BY started_at DESC
|
|
292
|
+
LIMIT ${limit}
|
|
293
|
+
`)
|
|
294
|
+
: await ctx.unsafeDb.execute(sql `
|
|
295
|
+
SELECT
|
|
296
|
+
id,
|
|
297
|
+
name,
|
|
298
|
+
args,
|
|
299
|
+
status,
|
|
300
|
+
current_step,
|
|
301
|
+
result,
|
|
302
|
+
error,
|
|
303
|
+
retry_count,
|
|
304
|
+
max_retries,
|
|
305
|
+
max_duration_ms,
|
|
306
|
+
started_at,
|
|
307
|
+
updated_at,
|
|
308
|
+
completed_at,
|
|
309
|
+
user_id
|
|
310
|
+
FROM _gencow_workflows
|
|
311
|
+
WHERE user_id = ${userId}
|
|
312
|
+
AND status = ${status}
|
|
313
|
+
ORDER BY started_at DESC
|
|
314
|
+
LIMIT ${limit}
|
|
315
|
+
`);
|
|
316
|
+
return rowsFromResult(result)
|
|
317
|
+
.map(mapWorkflowSummary)
|
|
318
|
+
.filter((row) => requestedStatus == null || row.derivedStatus === requestedStatus);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
package/package.json
CHANGED
|
@@ -1,46 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
},
|
|
14
|
-
"./server": {
|
|
15
|
-
"import": "./dist/server.js",
|
|
16
|
-
"require": "./dist/server.js",
|
|
17
|
-
"types": "./dist/server.d.ts"
|
|
18
|
-
}
|
|
2
|
+
"name": "@gencow/core",
|
|
3
|
+
"version": "0.1.25",
|
|
4
|
+
"description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
19
13
|
},
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"scripts": {
|
|
25
|
-
"db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
|
|
26
|
-
"build": "tsc",
|
|
27
|
-
"typecheck": "tsc --noEmit",
|
|
28
|
-
"prepublishOnly": "npm run build",
|
|
29
|
-
"postinstall": "tsc"
|
|
30
|
-
},
|
|
31
|
-
"dependencies": {
|
|
32
|
-
"@electric-sql/pglite": "^0.3.15",
|
|
33
|
-
"drizzle-orm": "^0.45.1",
|
|
34
|
-
"hono": "^4.12.0",
|
|
35
|
-
"node-cron": "^4.2.1"
|
|
36
|
-
},
|
|
37
|
-
"devDependencies": {
|
|
38
|
-
"@types/bun": "^1.3.9",
|
|
39
|
-
"@types/node": "^25.3.0",
|
|
40
|
-
"@types/node-cron": "^3.0.11",
|
|
41
|
-
"drizzle-kit": "^0.31.10",
|
|
42
|
-
"drizzle-seed": "^0.3.1",
|
|
43
|
-
"typescript": "^5.9.3",
|
|
44
|
-
"uuid": "^13.0.0"
|
|
14
|
+
"./server": {
|
|
15
|
+
"import": "./dist/server.js",
|
|
16
|
+
"require": "./dist/server.js",
|
|
17
|
+
"types": "./dist/server.d.ts"
|
|
45
18
|
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/",
|
|
22
|
+
"src/"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepublishOnly": "npm run build",
|
|
29
|
+
"postinstall": "tsc"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"node-cron": "^4.2.1"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"drizzle-orm": "^1.0.0-beta || ^1.0.0",
|
|
36
|
+
"hono": "^4.12.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@electric-sql/pglite": "^0.3.15",
|
|
40
|
+
"@types/bun": "^1.3.9",
|
|
41
|
+
"@types/node": "^25.3.0",
|
|
42
|
+
"@types/node-cron": "^3.0.11",
|
|
43
|
+
"drizzle-kit": "^1.0.0-beta || ^1.0.0",
|
|
44
|
+
"drizzle-orm": "^1.0.0-beta || ^1.0.0",
|
|
45
|
+
"drizzle-seed": "^1.0.0-beta",
|
|
46
|
+
"hono": "^4.12.0",
|
|
47
|
+
"typescript": "^5.9.3",
|
|
48
|
+
"uuid": "^13.0.0"
|
|
49
|
+
}
|
|
46
50
|
}
|
|
@@ -7,108 +7,112 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from "bun:test";
|
|
10
|
-
import { defineAuth } from "../auth-config";
|
|
11
|
-
import type { GencowAuthConfig } from "../auth-config";
|
|
10
|
+
import { defineAuth } from "../auth-config.js";
|
|
11
|
+
import type { GencowAuthConfig } from "../auth-config.js";
|
|
12
12
|
|
|
13
13
|
// ─── defineAuth() ───────────────────────────────────────
|
|
14
14
|
|
|
15
15
|
describe("defineAuth()", () => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
it("빈 설정 객체 반환", () => {
|
|
17
|
+
const config = defineAuth({});
|
|
18
|
+
expect(config).toEqual({});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("emailVerification 설정이 그대로 반환된다", () => {
|
|
22
|
+
const sendFn = async () => {};
|
|
23
|
+
const config = defineAuth({
|
|
24
|
+
emailVerification: {
|
|
25
|
+
sendOnSignUp: true,
|
|
26
|
+
requireEmailVerification: true,
|
|
27
|
+
autoSignInAfterVerification: true,
|
|
28
|
+
sendVerificationEmail: sendFn,
|
|
29
|
+
},
|
|
19
30
|
});
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
expect(config.emailVerification?.sendOnSignUp).toBe(true);
|
|
33
|
-
expect(config.emailVerification?.requireEmailVerification).toBe(true);
|
|
34
|
-
expect(config.emailVerification?.autoSignInAfterVerification).toBe(true);
|
|
35
|
-
expect(config.emailVerification?.sendVerificationEmail).toBe(sendFn);
|
|
32
|
+
expect(config.emailVerification?.sendOnSignUp).toBe(true);
|
|
33
|
+
expect(config.emailVerification?.requireEmailVerification).toBe(true);
|
|
34
|
+
expect(config.emailVerification?.autoSignInAfterVerification).toBe(true);
|
|
35
|
+
expect(config.emailVerification?.sendVerificationEmail).toBe(sendFn);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("부분 설정도 허용된다", () => {
|
|
39
|
+
const config = defineAuth({
|
|
40
|
+
emailVerification: {
|
|
41
|
+
sendVerificationEmail: async () => {},
|
|
42
|
+
},
|
|
36
43
|
});
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
sendVerificationEmail: async () => {},
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
expect(config.emailVerification?.sendOnSignUp).toBeUndefined();
|
|
46
|
-
expect(config.emailVerification?.sendVerificationEmail).toBeDefined();
|
|
47
|
-
});
|
|
45
|
+
expect(config.emailVerification?.sendOnSignUp).toBeUndefined();
|
|
46
|
+
expect(config.emailVerification?.sendVerificationEmail).toBeDefined();
|
|
47
|
+
});
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
// ─── AuthCtx interface 패턴 ─────────────────────────────
|
|
51
51
|
|
|
52
52
|
describe("AuthCtx 패턴 (mock)", () => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
53
|
+
// AuthCtx는 런타임에서 생성되므로, 인터페이스 수준에서 패턴 검증
|
|
54
|
+
|
|
55
|
+
it("getUserIdentity — 비로그인 시 null", () => {
|
|
56
|
+
const authCtx = {
|
|
57
|
+
getUserIdentity: () => null,
|
|
58
|
+
requireAuth: () => {
|
|
59
|
+
throw new Error("Authentication required");
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(authCtx.getUserIdentity()).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("getUserIdentity — 로그인 시 유저 반환", () => {
|
|
67
|
+
const user = { id: "u1", email: "test@test.com", name: "Test" };
|
|
68
|
+
const authCtx = {
|
|
69
|
+
getUserIdentity: () => user,
|
|
70
|
+
requireAuth: () => user,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
expect(authCtx.getUserIdentity()).toEqual(user);
|
|
74
|
+
expect(authCtx.getUserIdentity()!.id).toBe("u1");
|
|
75
|
+
expect(authCtx.getUserIdentity()!.email).toBe("test@test.com");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("requireAuth — 비로그인 시 에러 throw", () => {
|
|
79
|
+
const authCtx = {
|
|
80
|
+
getUserIdentity: () => null,
|
|
81
|
+
requireAuth: () => {
|
|
82
|
+
throw new Error("Authentication required");
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
expect(() => authCtx.requireAuth()).toThrow("Authentication required");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("requireAuth — 로그인 시 유저 반환 (throw 안 함)", () => {
|
|
90
|
+
const user = { id: "u2", email: "auth@test.com" };
|
|
91
|
+
const authCtx = {
|
|
92
|
+
getUserIdentity: () => user,
|
|
93
|
+
requireAuth: () => user,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
expect(() => authCtx.requireAuth()).not.toThrow();
|
|
97
|
+
expect(authCtx.requireAuth()).toEqual(user);
|
|
98
|
+
});
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
// ─── Secure by Default 검증 ─────────────────────────────
|
|
98
102
|
|
|
99
103
|
describe("Secure by Default — 기본 인증 필수", () => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
// query/mutation의 isPublic 기본값은 reactive.test.ts에서 검증됨.
|
|
105
|
+
// 여기서는 auth 엔드포인트 레벨 패턴만 확인.
|
|
106
|
+
|
|
107
|
+
it("공개(public) 쿼리는 auth 없이 실행 가능해야 함", () => {
|
|
108
|
+
// 이 테스트는 query({ public: true })의 isPublic 플래그 확인
|
|
109
|
+
// reactive.test.ts의 "Secure by Default" 섹션과 연계
|
|
110
|
+
const mockQueryDef = { isPublic: true, handler: async () => [] };
|
|
111
|
+
expect(mockQueryDef.isPublic).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("비공개 쿼리는 기본적으로 auth 필수", () => {
|
|
115
|
+
const mockQueryDef = { isPublic: false, handler: async () => [] };
|
|
116
|
+
expect(mockQueryDef.isPublic).toBe(false);
|
|
117
|
+
});
|
|
114
118
|
});
|