@uploadista/server 0.0.17 → 0.0.18-beta.10

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.
@@ -0,0 +1,219 @@
1
+ import { DeadLetterQueueService } from "@uploadista/core/flow";
2
+ import { Effect } from "effect";
3
+ import { PERMISSIONS } from "../../permissions/types";
4
+ import { AuthContextService } from "../../service";
5
+ import type {
6
+ DlqCleanupRequest,
7
+ DlqCleanupResponse,
8
+ DlqDeleteRequest,
9
+ DlqDeleteResponse,
10
+ DlqGetRequest,
11
+ DlqGetResponse,
12
+ DlqListRequest,
13
+ DlqListResponse,
14
+ DlqResolveRequest,
15
+ DlqResolveResponse,
16
+ DlqRetryAllRequest,
17
+ DlqRetryAllResponse,
18
+ DlqRetryRequest,
19
+ DlqRetryResponse,
20
+ DlqStatsRequest,
21
+ DlqStatsResponse,
22
+ } from "../routes";
23
+
24
+ /**
25
+ * Handle GET /api/dlq - List DLQ items
26
+ */
27
+ export const handleDlqList = (req: DlqListRequest) =>
28
+ Effect.gen(function* () {
29
+ const authService = yield* AuthContextService;
30
+
31
+ // Check permission for reading DLQ
32
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_READ);
33
+
34
+ const dlq = yield* DeadLetterQueueService;
35
+ const result = yield* dlq.list(req.options);
36
+
37
+ return {
38
+ type: "dlq-list",
39
+ status: 200,
40
+ headers: { "Content-Type": "application/json" },
41
+ body: result,
42
+ } satisfies DlqListResponse;
43
+ });
44
+
45
+ /**
46
+ * Handle GET /api/dlq/:itemId - Get a specific DLQ item
47
+ */
48
+ export const handleDlqGet = (req: DlqGetRequest) =>
49
+ Effect.gen(function* () {
50
+ const authService = yield* AuthContextService;
51
+
52
+ // Check permission for reading DLQ
53
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_READ);
54
+
55
+ const dlq = yield* DeadLetterQueueService;
56
+ const item = yield* dlq.get(req.itemId);
57
+
58
+ return {
59
+ type: "dlq-get",
60
+ status: 200,
61
+ headers: { "Content-Type": "application/json" },
62
+ body: item,
63
+ } satisfies DlqGetResponse;
64
+ });
65
+
66
+ /**
67
+ * Handle POST /api/dlq/:itemId/retry - Retry a specific DLQ item
68
+ */
69
+ export const handleDlqRetry = (req: DlqRetryRequest) =>
70
+ Effect.gen(function* () {
71
+ const authService = yield* AuthContextService;
72
+
73
+ // Check permission for writing to DLQ
74
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
75
+
76
+ const dlq = yield* DeadLetterQueueService;
77
+
78
+ // Mark item as retrying
79
+ yield* dlq.markRetrying(req.itemId);
80
+
81
+ // TODO: Implement actual retry logic by re-executing the flow
82
+ // This would require access to FlowServer and the original job context
83
+ // For now, we just mark it as retrying and return success
84
+ // The actual retry would be handled by a background scheduler
85
+
86
+ return {
87
+ type: "dlq-retry",
88
+ status: 200,
89
+ headers: { "Content-Type": "application/json" },
90
+ body: { success: true },
91
+ } satisfies DlqRetryResponse;
92
+ });
93
+
94
+ /**
95
+ * Handle POST /api/dlq/retry-all - Retry all matching DLQ items
96
+ */
97
+ export const handleDlqRetryAll = (req: DlqRetryAllRequest) =>
98
+ Effect.gen(function* () {
99
+ const authService = yield* AuthContextService;
100
+
101
+ // Check permission for writing to DLQ
102
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
103
+
104
+ const dlq = yield* DeadLetterQueueService;
105
+
106
+ // List items matching the filter
107
+ const { items } = yield* dlq.list({
108
+ status: req.options?.status,
109
+ flowId: req.options?.flowId,
110
+ });
111
+
112
+ let succeeded = 0;
113
+ let failed = 0;
114
+
115
+ // Mark each item for retry
116
+ for (const item of items) {
117
+ const result = yield* Effect.either(dlq.markRetrying(item.id));
118
+ if (result._tag === "Right") {
119
+ succeeded++;
120
+ } else {
121
+ failed++;
122
+ }
123
+ }
124
+
125
+ return {
126
+ type: "dlq-retry-all",
127
+ status: 200,
128
+ headers: { "Content-Type": "application/json" },
129
+ body: {
130
+ retried: items.length,
131
+ succeeded,
132
+ failed,
133
+ },
134
+ } satisfies DlqRetryAllResponse;
135
+ });
136
+
137
+ /**
138
+ * Handle DELETE /api/dlq/:itemId - Delete a DLQ item
139
+ */
140
+ export const handleDlqDelete = (req: DlqDeleteRequest) =>
141
+ Effect.gen(function* () {
142
+ const authService = yield* AuthContextService;
143
+
144
+ // Check permission for writing to DLQ
145
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
146
+
147
+ const dlq = yield* DeadLetterQueueService;
148
+ yield* dlq.delete(req.itemId);
149
+
150
+ return {
151
+ type: "dlq-delete",
152
+ status: 200,
153
+ headers: { "Content-Type": "application/json" },
154
+ body: { success: true },
155
+ } satisfies DlqDeleteResponse;
156
+ });
157
+
158
+ /**
159
+ * Handle POST /api/dlq/:itemId/resolve - Manually resolve a DLQ item
160
+ */
161
+ export const handleDlqResolve = (req: DlqResolveRequest) =>
162
+ Effect.gen(function* () {
163
+ const authService = yield* AuthContextService;
164
+
165
+ // Check permission for writing to DLQ
166
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
167
+
168
+ const dlq = yield* DeadLetterQueueService;
169
+ const item = yield* dlq.markResolved(req.itemId);
170
+
171
+ return {
172
+ type: "dlq-resolve",
173
+ status: 200,
174
+ headers: { "Content-Type": "application/json" },
175
+ body: item,
176
+ } satisfies DlqResolveResponse;
177
+ });
178
+
179
+ /**
180
+ * Handle POST /api/dlq/cleanup - Cleanup old DLQ items
181
+ */
182
+ export const handleDlqCleanup = (req: DlqCleanupRequest) =>
183
+ Effect.gen(function* () {
184
+ const authService = yield* AuthContextService;
185
+
186
+ // Check permission for writing to DLQ
187
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
188
+
189
+ const dlq = yield* DeadLetterQueueService;
190
+ const result = yield* dlq.cleanup(req.options);
191
+
192
+ return {
193
+ type: "dlq-cleanup",
194
+ status: 200,
195
+ headers: { "Content-Type": "application/json" },
196
+ body: result,
197
+ } satisfies DlqCleanupResponse;
198
+ });
199
+
200
+ /**
201
+ * Handle GET /api/dlq/stats - Get DLQ statistics
202
+ */
203
+ export const handleDlqStats = (_req: DlqStatsRequest) =>
204
+ Effect.gen(function* () {
205
+ const authService = yield* AuthContextService;
206
+
207
+ // Check permission for reading DLQ
208
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_READ);
209
+
210
+ const dlq = yield* DeadLetterQueueService;
211
+ const stats = yield* dlq.getStats();
212
+
213
+ return {
214
+ type: "dlq-stats",
215
+ status: 200,
216
+ headers: { "Content-Type": "application/json" },
217
+ body: stats,
218
+ } satisfies DlqStatsResponse;
219
+ });
@@ -1,7 +1,10 @@
1
1
  import { FlowServer } from "@uploadista/core/flow";
2
2
  import { Effect } from "effect";
3
3
  import { AuthCacheService } from "../../cache";
4
+ import { PERMISSIONS } from "../../permissions/types";
5
+ import { QuotaExceededError } from "../../permissions/errors";
4
6
  import { AuthContextService } from "../../service";
7
+ import { UsageHookService } from "../../usage-hooks/service";
5
8
  import type {
6
9
  CancelFlowRequest,
7
10
  CancelFlowResponse,
@@ -20,10 +23,12 @@ import type {
20
23
  export const handleGetFlow = ({ flowId }: GetFlowRequest) => {
21
24
  return Effect.gen(function* () {
22
25
  const flowServer = yield* FlowServer;
23
- // Access auth context if available
24
26
  const authService = yield* AuthContextService;
25
27
  const clientId = yield* authService.getClientId();
26
28
 
29
+ // Check permission for reading flow status
30
+ yield* authService.requirePermission(PERMISSIONS.FLOW.STATUS);
31
+
27
32
  if (clientId) {
28
33
  yield* Effect.logInfo(
29
34
  `[Flow] Getting flow data: ${flowId}, client: ${clientId}`,
@@ -46,11 +51,14 @@ export const handleRunFlow = <TRequirements>({
46
51
  }: RunFlowRequest) => {
47
52
  return Effect.gen(function* () {
48
53
  const flowServer = yield* FlowServer;
49
- // Access auth context if available
50
54
  const authService = yield* AuthContextService;
51
55
  const authCache = yield* AuthCacheService;
56
+ const usageHookService = yield* UsageHookService;
52
57
  const clientId = yield* authService.getClientId();
53
58
 
59
+ // Check permission for executing flows
60
+ yield* authService.requirePermission(PERMISSIONS.FLOW.EXECUTE);
61
+
54
62
  if (clientId) {
55
63
  yield* Effect.logInfo(
56
64
  `[Flow] Executing flow: ${flowId}, storage: ${storageId}, client: ${clientId}`,
@@ -65,7 +73,28 @@ export const handleRunFlow = <TRequirements>({
65
73
  );
66
74
  }
67
75
 
76
+ // Execute onFlowStart hook for quota checking
77
+ if (clientId) {
78
+ const hookResult = yield* usageHookService.onFlowStart({
79
+ clientId,
80
+ operation: "flow",
81
+ metadata: {
82
+ flowId,
83
+ },
84
+ });
85
+
86
+ if (hookResult.action === "abort") {
87
+ return yield* Effect.fail(
88
+ new QuotaExceededError(
89
+ hookResult.reason,
90
+ hookResult.code ?? "SUBSCRIPTION_REQUIRED",
91
+ ),
92
+ );
93
+ }
94
+ }
95
+
68
96
  // Run flow returns immediately with jobId
97
+ const startTime = Date.now();
69
98
  yield* Effect.logInfo(`[Flow] Calling flowServer.runFlow...`);
70
99
  const result = yield* flowServer
71
100
  .runFlow<TRequirements>({
@@ -91,6 +120,10 @@ export const handleRunFlow = <TRequirements>({
91
120
 
92
121
  yield* Effect.logInfo(`[Flow] Flow started with jobId: ${result.id}`);
93
122
 
123
+ // Note: onFlowComplete hook is called when the flow actually completes,
124
+ // which happens asynchronously. The hook should be invoked from the
125
+ // flow execution pipeline, not here where we just start the flow.
126
+
94
127
  return {
95
128
  status: 200,
96
129
  body: result,
@@ -101,11 +134,13 @@ export const handleRunFlow = <TRequirements>({
101
134
  export const handleJobStatus = ({ jobId }: GetJobStatusRequest) => {
102
135
  return Effect.gen(function* () {
103
136
  const flowServer = yield* FlowServer;
104
- // Access auth context if available
105
137
  const authService = yield* AuthContextService;
106
138
  const authCache = yield* AuthCacheService;
107
139
  const clientId = yield* authService.getClientId();
108
140
 
141
+ // Check permission for checking flow status
142
+ yield* authService.requirePermission(PERMISSIONS.FLOW.STATUS);
143
+
109
144
  if (!jobId) {
110
145
  throw new Error("No job id");
111
146
  }
@@ -142,10 +177,12 @@ export const handleResumeFlow = <TRequirements>({
142
177
  }: ResumeFlowRequest) => {
143
178
  return Effect.gen(function* () {
144
179
  const flowServer = yield* FlowServer;
145
- // Try to get auth from current request or cached auth
146
180
  const authService = yield* AuthContextService;
147
181
  const authCache = yield* AuthCacheService;
148
182
 
183
+ // Check permission for executing flows (resume is part of execution)
184
+ yield* authService.requirePermission(PERMISSIONS.FLOW.EXECUTE);
185
+
149
186
  // Try current auth first, fallback to cached auth
150
187
  let clientId = yield* authService.getClientId();
151
188
  if (!clientId) {
@@ -186,13 +223,16 @@ export const handleResumeFlow = <TRequirements>({
186
223
  } as ResumeFlowResponse;
187
224
  });
188
225
  };
226
+
189
227
  export const handlePauseFlow = ({ jobId }: PauseFlowRequest) => {
190
228
  return Effect.gen(function* () {
191
229
  const flowServer = yield* FlowServer;
192
- // Try to get auth from current request or cached auth
193
230
  const authService = yield* AuthContextService;
194
231
  const authCache = yield* AuthCacheService;
195
232
 
233
+ // Check permission for cancelling flows (pause is related to cancel)
234
+ yield* authService.requirePermission(PERMISSIONS.FLOW.CANCEL);
235
+
196
236
  // Try current auth first, fallback to cached auth
197
237
  let clientId = yield* authService.getClientId();
198
238
  if (!clientId) {
@@ -224,9 +264,12 @@ export const handlePauseFlow = ({ jobId }: PauseFlowRequest) => {
224
264
  export const handleCancelFlow = ({ jobId }: CancelFlowRequest) => {
225
265
  return Effect.gen(function* () {
226
266
  const flowServer = yield* FlowServer;
227
- // Try to get auth from current request or cached auth
228
267
  const authService = yield* AuthContextService;
229
268
  const authCache = yield* AuthCacheService;
269
+ const usageHookService = yield* UsageHookService;
270
+
271
+ // Check permission for cancelling flows
272
+ yield* authService.requirePermission(PERMISSIONS.FLOW.CANCEL);
230
273
 
231
274
  if (!jobId) {
232
275
  throw new Error("No job id");
@@ -253,6 +296,18 @@ export const handleCancelFlow = ({ jobId }: CancelFlowRequest) => {
253
296
  yield* Effect.logInfo(
254
297
  `[Flow] Flow cancelled, cleared auth cache: ${jobId}`,
255
298
  );
299
+
300
+ // Execute onFlowComplete hook for cancelled flow
301
+ yield* Effect.forkDaemon(
302
+ usageHookService.onFlowComplete({
303
+ clientId,
304
+ operation: "flow",
305
+ metadata: {
306
+ jobId,
307
+ status: "cancelled",
308
+ },
309
+ }),
310
+ );
256
311
  }
257
312
 
258
313
  return {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Health Check HTTP Handlers for Uploadista SDK.
3
+ *
4
+ * This module provides HTTP handlers for health check endpoints:
5
+ * - `/health` (liveness) - Simple alive check, no dependencies
6
+ * - `/ready` (readiness) - Full dependency check for accepting traffic
7
+ * - `/health/components` - Detailed component status for debugging
8
+ *
9
+ * @module core/http-handlers/health-http-handlers
10
+ */
11
+
12
+ import {
13
+ formatHealthAsText,
14
+ getHealthResponseFormat,
15
+ type HealthCheckConfig,
16
+ } from "@uploadista/core/types";
17
+ import { Effect } from "effect";
18
+ import { PERMISSIONS } from "../../permissions/types";
19
+ import { AuthContextService } from "../../service";
20
+ import {
21
+ createLivenessResponse,
22
+ performComponentsCheck,
23
+ performReadinessCheck,
24
+ } from "../health-check-service";
25
+ import type {
26
+ HealthComponentsRequest,
27
+ HealthComponentsResponse,
28
+ HealthReadyRequest,
29
+ HealthReadyResponse,
30
+ HealthRequest,
31
+ HealthResponse,
32
+ } from "../routes";
33
+
34
+ /**
35
+ * Handle GET /health - Liveness probe
36
+ *
37
+ * Returns immediately with 200 OK if the server is alive.
38
+ * Does not check any dependencies.
39
+ */
40
+ export const handleHealthLiveness = (
41
+ req: HealthRequest,
42
+ config?: HealthCheckConfig,
43
+ ) =>
44
+ Effect.sync(() => {
45
+ const response = createLivenessResponse(config);
46
+ const format = getHealthResponseFormat(req.acceptHeader);
47
+
48
+ if (format === "text") {
49
+ return {
50
+ type: "health",
51
+ status: 200,
52
+ headers: { "Content-Type": "text/plain" },
53
+ body: formatHealthAsText(response.status),
54
+ } as HealthResponse;
55
+ }
56
+
57
+ return {
58
+ type: "health",
59
+ status: 200,
60
+ headers: { "Content-Type": "application/json" },
61
+ body: response,
62
+ } satisfies HealthResponse;
63
+ });
64
+
65
+ /**
66
+ * Handle GET /ready - Readiness probe
67
+ *
68
+ * Checks all critical dependencies (storage, KV store) and returns:
69
+ * - 200 OK if all dependencies are healthy
70
+ * - 503 Service Unavailable if any critical dependency is unavailable
71
+ *
72
+ * Requires `engine:readiness` permission.
73
+ */
74
+ export const handleHealthReadiness = (
75
+ req: HealthReadyRequest,
76
+ config?: HealthCheckConfig,
77
+ ) =>
78
+ Effect.gen(function* () {
79
+ const authService = yield* AuthContextService;
80
+
81
+ // Check permission for readiness endpoint
82
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.READINESS);
83
+
84
+ const response = yield* performReadinessCheck(config);
85
+ const format = getHealthResponseFormat(req.acceptHeader);
86
+
87
+ // Determine HTTP status based on health status
88
+ const httpStatus = response.status === "unhealthy" ? 503 : 200;
89
+
90
+ if (format === "text") {
91
+ return {
92
+ type: "health-ready",
93
+ status: httpStatus,
94
+ headers: { "Content-Type": "text/plain" },
95
+ body: formatHealthAsText(response.status),
96
+ } as HealthReadyResponse;
97
+ }
98
+
99
+ return {
100
+ type: "health-ready",
101
+ status: httpStatus,
102
+ headers: { "Content-Type": "application/json" },
103
+ body: response,
104
+ } satisfies HealthReadyResponse;
105
+ });
106
+
107
+ /**
108
+ * Handle GET /health/components - Detailed component status
109
+ *
110
+ * Returns detailed health information for each component including:
111
+ * - Storage backend
112
+ * - KV store
113
+ * - Event broadcaster
114
+ * - Circuit breaker (if enabled)
115
+ * - Dead letter queue (if enabled)
116
+ *
117
+ * Always returns 200 OK for debugging purposes (even if components are degraded).
118
+ *
119
+ * Requires `engine:readiness` permission.
120
+ */
121
+ export const handleHealthComponents = (
122
+ req: HealthComponentsRequest,
123
+ config?: HealthCheckConfig,
124
+ ) =>
125
+ Effect.gen(function* () {
126
+ const authService = yield* AuthContextService;
127
+
128
+ // Check permission for components endpoint
129
+ yield* authService.requirePermission(PERMISSIONS.ENGINE.READINESS);
130
+
131
+ const response = yield* performComponentsCheck(config);
132
+ const format = getHealthResponseFormat(req.acceptHeader);
133
+
134
+ if (format === "text") {
135
+ // For text format, just return the overall status
136
+ return {
137
+ type: "health-components",
138
+ status: 200,
139
+ headers: { "Content-Type": "text/plain" },
140
+ body: formatHealthAsText(response.status),
141
+ } as HealthComponentsResponse;
142
+ }
143
+
144
+ return {
145
+ type: "health-components",
146
+ status: 200,
147
+ headers: { "Content-Type": "application/json" },
148
+ body: response,
149
+ } satisfies HealthComponentsResponse;
150
+ });
@@ -1,5 +1,16 @@
1
+ import type { HealthCheckConfig } from "@uploadista/core/types";
1
2
  import { Effect } from "effect";
2
3
  import type { UploadistaRequest, UploadistaResponse } from "../routes";
4
+ import {
5
+ handleDlqCleanup,
6
+ handleDlqDelete,
7
+ handleDlqGet,
8
+ handleDlqList,
9
+ handleDlqResolve,
10
+ handleDlqRetry,
11
+ handleDlqRetryAll,
12
+ handleDlqStats,
13
+ } from "./dlq-http-handlers";
3
14
  import {
4
15
  handleCancelFlow,
5
16
  handleGetFlow,
@@ -8,6 +19,11 @@ import {
8
19
  handleResumeFlow,
9
20
  handleRunFlow,
10
21
  } from "./flow-http-handlers";
22
+ import {
23
+ handleHealthComponents,
24
+ handleHealthLiveness,
25
+ handleHealthReadiness,
26
+ } from "./health-http-handlers";
11
27
  import {
12
28
  handleCreateUpload,
13
29
  handleGetCapabilities,
@@ -19,6 +35,7 @@ export type { UploadistaRequest, UploadistaResponse } from "../routes";
19
35
 
20
36
  export const handleUploadistaRequest = <TRequirements>(
21
37
  req: UploadistaRequest,
38
+ options?: { healthCheckConfig?: HealthCheckConfig },
22
39
  ) => {
23
40
  return Effect.gen(function* () {
24
41
  switch (req.type) {
@@ -44,6 +61,39 @@ export const handleUploadistaRequest = <TRequirements>(
44
61
  return (yield* handlePauseFlow(req)) as UploadistaResponse;
45
62
  case "cancel-flow":
46
63
  return (yield* handleCancelFlow(req)) as UploadistaResponse;
64
+ // DLQ Admin routes
65
+ case "dlq-list":
66
+ return (yield* handleDlqList(req)) as UploadistaResponse;
67
+ case "dlq-get":
68
+ return (yield* handleDlqGet(req)) as UploadistaResponse;
69
+ case "dlq-retry":
70
+ return (yield* handleDlqRetry(req)) as UploadistaResponse;
71
+ case "dlq-retry-all":
72
+ return (yield* handleDlqRetryAll(req)) as UploadistaResponse;
73
+ case "dlq-delete":
74
+ return (yield* handleDlqDelete(req)) as UploadistaResponse;
75
+ case "dlq-resolve":
76
+ return (yield* handleDlqResolve(req)) as UploadistaResponse;
77
+ case "dlq-cleanup":
78
+ return (yield* handleDlqCleanup(req)) as UploadistaResponse;
79
+ case "dlq-stats":
80
+ return (yield* handleDlqStats(req)) as UploadistaResponse;
81
+ // Health check routes
82
+ case "health":
83
+ return (yield* handleHealthLiveness(
84
+ req,
85
+ options?.healthCheckConfig,
86
+ )) as UploadistaResponse;
87
+ case "health-ready":
88
+ return (yield* handleHealthReadiness(
89
+ req,
90
+ options?.healthCheckConfig,
91
+ )) as UploadistaResponse;
92
+ case "health-components":
93
+ return (yield* handleHealthComponents(
94
+ req,
95
+ options?.healthCheckConfig,
96
+ )) as UploadistaResponse;
47
97
  case "not-found":
48
98
  return {
49
99
  status: 404,