startx 1.0.4 → 1.0.8

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.
Files changed (47) hide show
  1. package/_gitignore +40 -0
  2. package/apps/core-server/Dockerfile +7 -2
  3. package/apps/core-server/src/middlewares/auth-middleware.ts +74 -30
  4. package/apps/queue-worker/Dockerfile +15 -8
  5. package/apps/queue-worker/package.json +5 -1
  6. package/apps/queue-worker/src/bullmq/board.ts +28 -0
  7. package/apps/queue-worker/src/index.ts +2 -0
  8. package/apps/startx-cli/dist/index.mjs +2 -2
  9. package/apps/startx-cli/src/commands/init.ts +2 -1
  10. package/apps/startx-cli/src/configs/scripts.ts +40 -0
  11. package/apps/web-client/react-router.config.ts +1 -0
  12. package/package.json +8 -2
  13. package/packages/@db/drizzle/drizzle.config.ts +1 -1
  14. package/packages/@db/drizzle/src/index.ts +4 -15
  15. package/packages/@repo/lib/src/cookie-module/cookie-module.ts +94 -38
  16. package/packages/@repo/lib/src/extra/index.ts +1 -0
  17. package/packages/@repo/lib/src/extra/token-module.ts +50 -21
  18. package/packages/@repo/lib/src/mail-module/nodemailer.ts +33 -21
  19. package/packages/@repo/lib/src/session-module/i-session.ts +132 -59
  20. package/packages/@repo/lib/src/session-module/index.ts +8 -2
  21. package/packages/@repo/lib/src/session-module/redis-session.ts +53 -23
  22. package/packages/@repo/lib/src/validation-module/index.ts +50 -78
  23. package/packages/@repo/model/eslint.config.ts +4 -0
  24. package/packages/@repo/model/package.json +41 -0
  25. package/packages/@repo/model/src/index.ts +0 -0
  26. package/packages/@repo/model/tsconfig.json +7 -0
  27. package/packages/@repo/model/vitest.config.ts +3 -0
  28. package/packages/common/src/time.ts +95 -22
  29. package/packages/queue/src/adapter/bullmq-adapter.ts +138 -47
  30. package/packages/queue/src/index.ts +3 -0
  31. package/packages/queue/src/queue-interface.ts +12 -5
  32. package/packages/queue/src/registry.ts +2 -2
  33. package/packages/queue/tsconfig.json +1 -1
  34. package/packages/ui/src/api/use-api/react-query/types.ts +3 -3
  35. package/packages/ui/src/api/use-api/react-query/use-api.ts +10 -11
  36. package/pnpm-workspace.yaml +4 -2
  37. package/turbo.json +20 -0
  38. /package/apps/web-client/{app → src}/app.css +0 -0
  39. /package/apps/web-client/{app → src}/components.json +0 -0
  40. /package/apps/web-client/{app → src}/config/auth/auth-state.ts +0 -0
  41. /package/apps/web-client/{app → src}/config/axios-client.ts +0 -0
  42. /package/apps/web-client/{app → src}/config/env.ts +0 -0
  43. /package/apps/web-client/{app → src}/entry.client.tsx +0 -0
  44. /package/apps/web-client/{app → src}/eslint.config.ts +0 -0
  45. /package/apps/web-client/{app → src}/root.tsx +0 -0
  46. /package/apps/web-client/{app → src}/routes/home.tsx +0 -0
  47. /package/apps/web-client/{app → src}/routes.ts +0 -0
@@ -1,18 +1,25 @@
1
1
  import { logger } from "@repo/logger";
2
- import { Queue, Worker, QueueEvents, type ConnectionOptions, type JobsOptions } from "bullmq";
3
- import type { IQueueProvider } from "../queue-interface.js";
2
+ import { Queue, Worker, QueueEvents, type ConnectionOptions, type JobsOptions, type WorkerOptions } from "bullmq";
3
+
4
+ import type { IQueueProvider, RegisterCronConfig } from "../queue-interface.js";
4
5
  import { JobSchemas, type JobRegistry } from "../registry.js";
5
- import type { JobOptions, JobHandler, JobContext } from "../types.js";
6
+ import type { JobContext, JobHandler, JobOptions } from "../types.js";
6
7
 
7
8
  export class BullMQProvider implements IQueueProvider {
8
9
  private queues: Map<string, Queue> = new Map();
9
- private workers: Worker[] = [];
10
+ private workers: Map<string, Worker> = new Map();
10
11
  private events: Map<string, QueueEvents> = new Map();
11
12
 
12
- constructor(private connection: ConnectionOptions = { host: "localhost", port: 6379 }) {}
13
+ constructor(
14
+ private connection: ConnectionOptions = {
15
+ host: "localhost",
16
+ port: 6379,
17
+ }
18
+ ) {}
13
19
 
14
20
  private mapOptions(options?: JobOptions): JobsOptions {
15
21
  if (!options) return {};
22
+
16
23
  return {
17
24
  jobId: options.jobId,
18
25
  delay: options.delay,
@@ -21,10 +28,15 @@ export class BullMQProvider implements IQueueProvider {
21
28
  };
22
29
  }
23
30
 
24
- private getQueue(queueName: string): Queue {
31
+ getQueue(queueName: string): Queue {
25
32
  if (!this.queues.has(queueName)) {
26
- this.queues.set(queueName, new Queue(queueName, { connection: this.connection }));
33
+ const queue = new Queue(queueName, {
34
+ connection: this.connection,
35
+ });
36
+
37
+ this.queues.set(queueName, queue);
27
38
  }
39
+
28
40
  return this.queues.get(queueName)!;
29
41
  }
30
42
 
@@ -34,9 +46,10 @@ export class BullMQProvider implements IQueueProvider {
34
46
  options?: JobOptions
35
47
  ): Promise<string> {
36
48
  const validated = JobSchemas[queueName].params.parse(params);
37
- const queue = this.getQueue(queueName as string);
49
+ const queue = this.getQueue(queueName);
38
50
  const job = await queue.add("job", validated, this.mapOptions(options));
39
- return job.id!;
51
+
52
+ return String(job.id);
40
53
  }
41
54
 
42
55
  async enqueueMany<K extends keyof JobRegistry>(
@@ -44,52 +57,115 @@ export class BullMQProvider implements IQueueProvider {
44
57
  paramsList: Array<JobRegistry[K]["params"]>,
45
58
  options?: JobOptions
46
59
  ): Promise<string[]> {
47
- const queue = this.getQueue(queueName as string);
48
- const bullOptions = this.mapOptions(options);
60
+ if (options?.jobId && paramsList.length > 1) {
61
+ throw new Error("`jobId` cannot be shared across multiple jobs in enqueueMany()");
62
+ }
63
+
64
+ const queue = this.getQueue(queueName);
65
+ const jobs = await queue.addBulk(
66
+ paramsList.map(params => ({
67
+ name: "job",
68
+ data: JobSchemas[queueName].params.parse(params),
69
+ opts: this.mapOptions(options),
70
+ }))
71
+ );
72
+
73
+ return jobs.map(job => String(job.id));
74
+ }
75
+
76
+ async registerCron<K extends keyof JobRegistry>(config: RegisterCronConfig<K>): Promise<void> {
77
+ const { queueName, schedulerId, cronExpression, params, handler, options } = config;
49
78
 
50
- const jobsToPush = paramsList.map(params => ({
51
- name: "job",
52
- data: JobSchemas[queueName].params.parse(params) as JobRegistry[K]["params"],
53
- opts: bullOptions,
54
- }));
79
+ const validated = JobSchemas[queueName].params.parse(params);
80
+ const queue = this.getQueue(queueName);
81
+
82
+ await queue.upsertJobScheduler(
83
+ schedulerId,
84
+ { pattern: cronExpression },
85
+ {
86
+ name: "cron-job",
87
+ data: validated,
88
+ opts: this.mapOptions(options),
89
+ }
90
+ );
91
+
92
+ this.registerWorker(queueName, handler, options);
93
+ }
55
94
 
56
- const jobs = await queue.addBulk(jobsToPush);
57
- return jobs.map(j => j.id!);
95
+ async removeCron<K extends keyof JobRegistry>(queueName: K, schedulerId: string): Promise<boolean> {
96
+ const queue = this.getQueue(queueName);
97
+ return await queue.removeJobScheduler(schedulerId);
58
98
  }
59
99
 
60
100
  registerWorker<K extends keyof JobRegistry>(
61
101
  queueName: K,
62
102
  handler: JobHandler<JobRegistry[K]["params"], JobRegistry[K]["result"]>,
63
- options: {
64
- concurrency?: number;
65
- }
103
+ options?: Omit<WorkerOptions, "connection">
66
104
  ): void {
67
- logger.info(
68
- `Registering worker for queue ${queueName} with options ${Object.entries(options)
69
- .map(([k, v]) => `${k}: ${v}`)
70
- .join(", ")}`
71
- );
72
- const worker = new Worker(
73
- queueName as string,
74
- async job => {
75
- const validData = JobSchemas[queueName].params.parse(job.data) as JobRegistry[K]["params"];
105
+ if (this.workers.has(queueName)) {
106
+ return;
107
+ }
76
108
 
77
- const context: JobContext = {
78
- jobId: job.id!,
79
- attemptsMade: job.attemptsMade,
80
- };
109
+ logger.info(`Registering worker`, { queue: queueName, options });
81
110
 
82
- const result = await handler(validData, context);
83
- return result;
111
+ const worker = new Worker(
112
+ queueName,
113
+ async job => {
114
+ try {
115
+ const validData = JobSchemas[queueName].params.parse(job.data) as JobRegistry[K]["params"];
116
+ const context: JobContext = {
117
+ jobId: String(job.id),
118
+ attemptsMade: job.attemptsMade,
119
+ };
120
+ return await handler(validData, context);
121
+ } catch (error) {
122
+ logger.error(`Job execution failed`, {
123
+ queue: queueName,
124
+ jobId: job.id,
125
+ attemptsMade: job.attemptsMade,
126
+ data: job.data,
127
+ error,
128
+ });
129
+ throw error;
130
+ }
84
131
  },
85
- { connection: this.connection, concurrency: options.concurrency }
132
+ {
133
+ connection: this.connection,
134
+ ...options,
135
+ }
86
136
  );
87
- this.workers.push(worker);
137
+
138
+ worker.on("ready", () => logger.info(`Worker ready`, { queue: queueName }));
139
+ worker.on("active", job => logger.debug(`Job started`, { queue: queueName, jobId: job.id }));
140
+ worker.on("completed", job =>
141
+ logger.info(`Job completed`, { queue: queueName, jobId: job.id, attemptsMade: job.attemptsMade })
142
+ );
143
+ worker.on("failed", (job, error) =>
144
+ logger.error(`Job failed`, { queue: queueName, jobId: job?.id, attemptsMade: job?.attemptsMade, error })
145
+ );
146
+ worker.on("stalled", jobId => logger.warn(`Job stalled`, { queue: queueName, jobId }));
147
+ worker.on("closing", () => logger.warn(`Worker closing`, { queue: queueName }));
148
+ worker.on("closed", () => logger.warn(`Worker closed`, { queue: queueName }));
149
+ worker.on("error", error => logger.error(`Worker error`, { queue: queueName, error }));
150
+
151
+ void worker.client.then(client => {
152
+ client.on("ready", () => logger.info(`Redis ready`, { queue: queueName }));
153
+ client.on("reconnecting", () => logger.warn(`Redis reconnecting`, { queue: queueName }));
154
+ client.on("end", () => logger.error(`Redis connection ended`, { queue: queueName }));
155
+ client.on("error", error => logger.error(`Redis connection error`, { queue: queueName, error }));
156
+ });
157
+
158
+ this.workers.set(queueName, worker);
88
159
  }
89
160
 
90
161
  private getQueueEvents(queueName: string): QueueEvents {
91
162
  if (!this.events.has(queueName)) {
92
- this.events.set(queueName, new QueueEvents(queueName, { connection: this.connection }));
163
+ const events = new QueueEvents(queueName, {
164
+ connection: this.connection,
165
+ });
166
+
167
+ events.on("error", error => logger.error(`QueueEvents error`, { queue: queueName, error }));
168
+ this.events.set(queueName, events);
93
169
  }
94
170
  return this.events.get(queueName)!;
95
171
  }
@@ -98,24 +174,39 @@ export class BullMQProvider implements IQueueProvider {
98
174
  queueName: K,
99
175
  callback: (jobId: string, result: JobRegistry[K]["result"]) => void
100
176
  ): void {
101
- const events = this.getQueueEvents(queueName as string);
177
+ const events = this.getQueueEvents(queueName);
102
178
  events.on("completed", ({ jobId, returnvalue }) => {
103
- // Cast the Redis returnvalue to our strongly typed result
104
- callback(jobId, returnvalue as JobRegistry[K]["result"]);
179
+ try {
180
+ callback(jobId, returnvalue as JobRegistry[K]["result"]);
181
+ } catch (error) {
182
+ logger.error("Job completion callback failed", { queue: queueName, jobId, error });
183
+ }
105
184
  });
106
185
  }
107
186
 
108
187
  onJobFailed<K extends keyof JobRegistry>(queueName: K, callback: (jobId: string, error: Error) => void): void {
109
- const events = this.getQueueEvents(queueName as string);
110
- events.on("failed", ({ jobId, failedReason }) => callback(jobId, new Error(failedReason)));
188
+ const events = this.getQueueEvents(queueName);
189
+ events.on("failed", ({ jobId, failedReason }) => {
190
+ try {
191
+ callback(jobId, new Error(failedReason));
192
+ } catch (error) {
193
+ logger.error("Job failure callback failed", { queue: queueName, jobId, error });
194
+ }
195
+ });
111
196
  }
112
197
 
113
198
  async close(): Promise<void> {
114
199
  logger.warn("Shutting down queue system...");
115
200
 
116
- await Promise.all(this.workers.map(w => w.close()));
117
- await Promise.all(Array.from(this.events.values()).map(e => e.close()));
118
- await Promise.all(Array.from(this.queues.values()).map(q => q.close()));
201
+ const workerResults = await Promise.allSettled(Array.from(this.workers.values()).map(worker => worker.close()));
202
+ const eventResults = await Promise.allSettled(Array.from(this.events.values()).map(event => event.close()));
203
+ const queueResults = await Promise.allSettled(Array.from(this.queues.values()).map(queue => queue.close()));
204
+
205
+ [...workerResults, ...eventResults, ...queueResults]
206
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
207
+ .forEach(result => {
208
+ logger.error("Error during shutdown", result.reason);
209
+ });
119
210
 
120
211
  logger.warn("Queue system shut down safely.");
121
212
  }
@@ -1,5 +1,8 @@
1
1
  import { getRedis } from "@repo/redis";
2
2
  import { BullMQProvider } from "./adapter/bullmq-adapter.js";
3
+ import { JobSchemas } from "./registry.js";
3
4
 
4
5
  const redisConnection = getRedis({ db: 10 });
6
+ export const queueList = Object.keys(JobSchemas) as Array<keyof typeof JobSchemas>;
7
+
5
8
  export const BullQueue = new BullMQProvider(redisConnection);
@@ -1,6 +1,14 @@
1
+ import type { WorkerOptions } from "bullmq";
1
2
  import type { JobRegistry } from "./registry.js";
2
3
  import type { JobOptions, JobHandler } from "./types.js";
3
-
4
+ export interface RegisterCronConfig<K extends keyof JobRegistry> {
5
+ queueName: K;
6
+ schedulerId: string;
7
+ cronExpression: string;
8
+ params: JobRegistry[K]["params"];
9
+ handler: JobHandler<JobRegistry[K]["params"], JobRegistry[K]["result"]>;
10
+ options?: Omit<JobOptions, "delay" | "jobId"> & Omit<WorkerOptions, "connection">;
11
+ }
4
12
  export interface IQueueProvider {
5
13
  enqueue<K extends keyof JobRegistry>(
6
14
  queueName: K,
@@ -17,11 +25,10 @@ export interface IQueueProvider {
17
25
  registerWorker<K extends keyof JobRegistry>(
18
26
  queueName: K,
19
27
  handler: JobHandler<JobRegistry[K]["params"], JobRegistry[K]["result"]>,
20
- options: {
21
- concurrency?: number;
22
- }
28
+ options?: unknown
23
29
  ): void;
24
-
30
+ registerCron<K extends keyof JobRegistry>(config: RegisterCronConfig<K>): Promise<void>;
31
+ removeCron<K extends keyof JobRegistry>(queueName: K, schedulerId: string): Promise<boolean>;
25
32
  onJobComplete<K extends keyof JobRegistry>(
26
33
  queueName: K,
27
34
  callback: (jobId: string, result: JobRegistry[K]["result"]) => void
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
- import { emailQueue } from "./common/email-queue.js";
2
+ import { commonQueue } from "./common/index.js";
3
3
 
4
4
  export const JobSchemas = {
5
- ...emailQueue,
5
+ ...commonQueue,
6
6
  } as const;
7
7
 
8
8
  export type JobRegistry = {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "typescript-config/tsconfig.node.json",
3
3
  "compilerOptions": {
4
- "baseUrl": "./src"
4
+ // "baseUrl": "./src"
5
5
  },
6
6
  "include": ["src/**/*.ts"]
7
7
  }
@@ -2,9 +2,9 @@ import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query";
2
2
  import type { z } from "zod";
3
3
  import type { IPaginatedData, TimeString } from "../api-types";
4
4
 
5
- export type ExtractData<E> = "data" extends keyof E ? E["data"] : unknown;
6
- export type ExtractZQuery<E> = "zQuery" extends keyof E ? E["zQuery"] : never;
7
- export type ExtractZParams<E> = "zParams" extends keyof E ? E["zParams"] : never;
5
+ export type ExtractData<E> = "data" extends keyof E ? Exclude<E["data"], undefined> : unknown;
6
+ export type ExtractZQuery<E> = "zQuery" extends keyof E ? NonNullable<E["zQuery"]> : never;
7
+ export type ExtractZParams<E> = "zParams" extends keyof E ? NonNullable<E["zParams"]> : never;
8
8
  export type ExtractZBody<E> = "zBody" extends keyof E ? NonNullable<E["zBody"]> : never;
9
9
 
10
10
  export type WithAbort<T> = T & { abort: () => void };
@@ -126,7 +126,6 @@ function useFetchApi<ID, ZQ extends ZQuery, ZP extends ZParams>(
126
126
  abort: () => queryClient.cancelQueries({ queryKey }),
127
127
  };
128
128
  }
129
-
130
129
  function usePaginatedFetchApi<ID, IO, ZQ extends ZQuery, ZP extends ZParams>(
131
130
  key: keyof RawSchema,
132
131
  endpoint: IPaginatedFetchOptions<ID, IO, ZQ, ZP>,
@@ -139,7 +138,7 @@ function usePaginatedFetchApi<ID, IO, ZQ extends ZQuery, ZP extends ZParams>(
139
138
  staleTime?: number;
140
139
  enabled?: boolean;
141
140
  }
142
- ): UseQueryResult<{ data: IPaginatedData<ID, IO> }> & { abort: () => void } {
141
+ ): UseQueryResult<IPaginatedData<ID, IO>> & { abort: () => void } {
143
142
  const queryClient = useQueryClient();
144
143
  const mergedQuery = useMemo(
145
144
  () =>
@@ -161,10 +160,13 @@ function usePaginatedFetchApi<ID, IO, ZQ extends ZQuery, ZP extends ZParams>(
161
160
 
162
161
  const staleTime = ApiHelper.parseTime(ApiHelper.merge(options.staleTime, endpoint.staleTime));
163
162
 
164
- const query = useQuery<{ data: IPaginatedData<ID, IO> }>({
163
+ const query = useQuery<IPaginatedData<ID, IO>>({
165
164
  queryKey,
166
165
  queryFn: async ({ signal }) => {
167
- const config: AxiosRequestConfig = {
166
+ const resp = await axiosClient.request<{
167
+ message: string;
168
+ data: IPaginatedData<ID, IO>;
169
+ }>({
168
170
  method: endpoint.method || "GET",
169
171
  url: ApiHelper.buildUrl({
170
172
  route: endpoint.route,
@@ -172,13 +174,9 @@ function usePaginatedFetchApi<ID, IO, ZQ extends ZQuery, ZP extends ZParams>(
172
174
  searchParams: mergedQuery,
173
175
  }),
174
176
  signal,
175
- };
177
+ });
176
178
 
177
- const resp = await axiosClient.request<{
178
- data: IPaginatedData<ID, IO>;
179
- }>(config);
180
-
181
- return resp.data;
179
+ return resp.data.data;
182
180
  },
183
181
  staleTime,
184
182
  enabled: options.enabled ?? endpoint.enable?.isEnable ?? true,
@@ -188,13 +186,14 @@ function usePaginatedFetchApi<ID, IO, ZQ extends ZQuery, ZP extends ZParams>(
188
186
  refetchIntervalInBackground: endpoint.refetch?.interval?.inBackground,
189
187
  refetchOnMount: endpoint.refetch?.onMount ? "always" : false,
190
188
  refetchOnWindowFocus: endpoint.refetch?.onFocus ? "always" : false,
191
- } as UseQueryOptions<{ data: IPaginatedData<ID, IO> }>);
189
+ });
192
190
 
193
191
  return {
194
192
  ...query,
195
193
  abort: () => queryClient.cancelQueries({ queryKey }),
196
194
  };
197
195
  }
196
+
198
197
  function useMutationApi<Schema extends RawSchema, K extends keyof Schema & string>(
199
198
  endpoint: IFetchMutationOptions<K>,
200
199
  axiosClient: AxiosInstance,
@@ -75,8 +75,8 @@ catalog:
75
75
  "@types/morgan": "^1.9.10"
76
76
 
77
77
  # db
78
- drizzle-orm: "^0.45.1"
79
- drizzle-kit: "^0.31.10"
78
+ drizzle-orm: "^1.0.0-beta.22"
79
+ drizzle-kit: "^1.0.0-beta.22"
80
80
  pg: "^8.19.0"
81
81
  better-sqlite3: "^12.9.0"
82
82
 
@@ -102,6 +102,8 @@ catalog:
102
102
 
103
103
  # bullmq
104
104
  bullmq: "^5.76.4"
105
+ "@bull-board/express": "^7.1.5"
106
+ "@bull-board/api": "^7.1.5"
105
107
 
106
108
  # redis
107
109
  ioredis: "^5.10.1"
package/turbo.json CHANGED
@@ -69,6 +69,26 @@
69
69
  "cache": false,
70
70
  "interactive": true
71
71
  },
72
+ "db:migrate": {
73
+ "cache": false,
74
+ "interactive": true
75
+ },
76
+ "db:up": {
77
+ "cache": false,
78
+ "interactive": true
79
+ },
80
+ "db:check": {
81
+ "cache": false,
82
+ "interactive": true
83
+ },
84
+ "db:pull": {
85
+ "cache": false,
86
+ "interactive": true
87
+ },
88
+ "db:generate": {
89
+ "cache": false,
90
+ "interactive": true
91
+ },
72
92
  "db:studio": {
73
93
  "cache": false,
74
94
  "interactive": false,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes