@carlos-tzin/tzin 0.1.8 → 0.1.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.
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,104 @@
1
+ export interface JobConfig {
2
+ /** Unique job name */
3
+ name: string;
4
+ /** Max retries on failure (default: 3) */
5
+ maxRetries?: number;
6
+ /** Delay between retries in ms (default: 1000) */
7
+ retryDelay?: number;
8
+ /** Job timeout in ms (default: 30000) */
9
+ timeout?: number;
10
+ }
11
+ export interface JobDefinition<Payload = unknown> {
12
+ readonly name: string;
13
+ readonly config: JobConfig;
14
+ /** Process the job */
15
+ handler: (payload: Payload, ctx: JobContext) => Promise<void>;
16
+ }
17
+ export interface JobContext {
18
+ /** Job attempt number (0-based) */
19
+ attempt: number;
20
+ /** Abort signal for cancellation */
21
+ signal: AbortSignal;
22
+ /** Logger scoped to this job */
23
+ log: JobLogger;
24
+ }
25
+ export interface JobLogger {
26
+ info(message: string, data?: Record<string, unknown>): void;
27
+ warn(message: string, data?: Record<string, unknown>): void;
28
+ error(message: string, data?: Record<string, unknown>): void;
29
+ }
30
+ export interface Job<Payload = unknown> {
31
+ /** Job name */
32
+ readonly name: string;
33
+ /** Job configuration */
34
+ readonly config: JobConfig;
35
+ /** Enqueue the job for processing */
36
+ enqueue(payload: Payload, options?: EnqueueOptions): Promise<JobHandle>;
37
+ }
38
+ export interface EnqueueOptions {
39
+ /** Delay before processing in ms */
40
+ delay?: number;
41
+ /** Schedule for a specific time */
42
+ scheduledAt?: Date;
43
+ }
44
+ export interface JobHandle {
45
+ /** Job ID */
46
+ id: string;
47
+ /** Poll for completion */
48
+ wait(): Promise<JobResult>;
49
+ }
50
+ export interface JobResult {
51
+ status: 'completed' | 'failed';
52
+ error?: string;
53
+ duration: number;
54
+ }
55
+ export type JobStatus = 'pending' | 'running' | 'completed' | 'failed';
56
+ export interface JobRecord {
57
+ id: string;
58
+ name: string;
59
+ payload: unknown;
60
+ status: JobStatus;
61
+ attempt: number;
62
+ maxRetries: number;
63
+ error?: string;
64
+ createdAt: Date;
65
+ startedAt?: Date;
66
+ completedAt?: Date;
67
+ }
68
+ export interface JobStore {
69
+ add(record: JobRecord): void;
70
+ update(id: string, data: Partial<JobRecord>): void;
71
+ getById(id: string): JobRecord | null;
72
+ getPending(): JobRecord[];
73
+ }
74
+ /**
75
+ * Define a background job.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import { defineJob } from '@carlos-tzin/tzin/jobs'
80
+ *
81
+ * const sendEmail = defineJob<{ to: string; subject: string; body: string }>({
82
+ * name: 'send-email',
83
+ * maxRetries: 3,
84
+ * handler: async (payload, ctx) => {
85
+ * ctx.log.info('Sending email', { to: payload.to })
86
+ * await resend.emails.send({ ... })
87
+ * },
88
+ * })
89
+ *
90
+ * // In a handler:
91
+ * await sendEmail.enqueue({ to: 'ada@example.com', subject: 'Hello', body: '...' })
92
+ * ```
93
+ */
94
+ export declare function defineJob<Payload = void>(config: JobConfig & {
95
+ handler: (payload: Payload, ctx: JobContext) => Promise<void>;
96
+ }): Job<Payload>;
97
+ /**
98
+ * Get all job records (useful for debugging).
99
+ */
100
+ export declare function getJobRecords(): JobRecord[];
101
+ /**
102
+ * Reset the job store (useful for testing).
103
+ */
104
+ export declare function resetJobs(): void;
package/dist/jobs.js ADDED
@@ -0,0 +1,202 @@
1
+ // ── Types ────────────────────────────────────────────────────────────
2
+ class MemoryJobStore {
3
+ records = new Map();
4
+ add(record) {
5
+ this.records.set(record.id, { ...record });
6
+ }
7
+ update(id, data) {
8
+ const record = this.records.get(id);
9
+ if (record)
10
+ Object.assign(record, data);
11
+ }
12
+ getById(id) {
13
+ const record = this.records.get(id);
14
+ return record ? { ...record } : null;
15
+ }
16
+ getPending() {
17
+ return [...this.records.values()].filter((r) => r.status === 'pending');
18
+ }
19
+ }
20
+ // ── Queue State ──────────────────────────────────────────────────────
21
+ let globalStore = new MemoryJobStore();
22
+ let processing = false;
23
+ let processTimer = null;
24
+ const handlers = new Map();
25
+ // ── defineJob ────────────────────────────────────────────────────────
26
+ /**
27
+ * Define a background job.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { defineJob } from '@carlos-tzin/tzin/jobs'
32
+ *
33
+ * const sendEmail = defineJob<{ to: string; subject: string; body: string }>({
34
+ * name: 'send-email',
35
+ * maxRetries: 3,
36
+ * handler: async (payload, ctx) => {
37
+ * ctx.log.info('Sending email', { to: payload.to })
38
+ * await resend.emails.send({ ... })
39
+ * },
40
+ * })
41
+ *
42
+ * // In a handler:
43
+ * await sendEmail.enqueue({ to: 'ada@example.com', subject: 'Hello', body: '...' })
44
+ * ```
45
+ */
46
+ export function defineJob(config) {
47
+ const jobConfig = {
48
+ maxRetries: 3,
49
+ retryDelay: 1000,
50
+ timeout: 30000,
51
+ ...config,
52
+ };
53
+ handlers.set(config.name, config.handler);
54
+ return {
55
+ name: config.name,
56
+ config: jobConfig,
57
+ async enqueue(payload, options) {
58
+ const id = `${config.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
59
+ const record = {
60
+ id,
61
+ name: config.name,
62
+ payload,
63
+ status: 'pending',
64
+ attempt: 0,
65
+ maxRetries: jobConfig.maxRetries,
66
+ createdAt: new Date(),
67
+ ...(options?.scheduledAt && { scheduledAt: options.scheduledAt }),
68
+ };
69
+ globalStore.add(record);
70
+ scheduleProcess();
71
+ return {
72
+ id,
73
+ async wait() {
74
+ return waitForJob(id, jobConfig.timeout * (jobConfig.maxRetries + 1));
75
+ },
76
+ };
77
+ },
78
+ };
79
+ }
80
+ // ── Processing ───────────────────────────────────────────────────────
81
+ function scheduleProcess() {
82
+ if (processTimer)
83
+ return;
84
+ processTimer = setTimeout(async () => {
85
+ processTimer = null;
86
+ await processPending();
87
+ }, 10);
88
+ }
89
+ async function processPending() {
90
+ if (processing)
91
+ return;
92
+ processing = true;
93
+ try {
94
+ const pending = globalStore.getPending();
95
+ for (const record of pending) {
96
+ await processRecord(record);
97
+ }
98
+ }
99
+ finally {
100
+ processing = false;
101
+ if (globalStore.getPending().length > 0) {
102
+ scheduleProcess();
103
+ }
104
+ }
105
+ }
106
+ async function processRecord(record) {
107
+ const handler = handlers.get(record.name);
108
+ if (!handler) {
109
+ globalStore.update(record.id, { status: 'failed', error: 'No handler found' });
110
+ return;
111
+ }
112
+ globalStore.update(record.id, { status: 'running', startedAt: new Date() });
113
+ const controller = new AbortController();
114
+ const ctx = {
115
+ attempt: record.attempt,
116
+ signal: controller.signal,
117
+ log: createLogger(record.name),
118
+ };
119
+ try {
120
+ await Promise.race([
121
+ handler(record.payload, ctx),
122
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Job timeout')), 30000)),
123
+ ]);
124
+ globalStore.update(record.id, {
125
+ status: 'completed',
126
+ completedAt: new Date(),
127
+ });
128
+ }
129
+ catch (err) {
130
+ const error = err instanceof Error ? err.message : String(err);
131
+ const attempt = record.attempt + 1;
132
+ if (attempt < record.maxRetries) {
133
+ globalStore.update(record.id, { status: 'pending', attempt });
134
+ setTimeout(() => scheduleProcess(), 1000);
135
+ }
136
+ else {
137
+ globalStore.update(record.id, {
138
+ status: 'failed',
139
+ error,
140
+ attempt,
141
+ completedAt: new Date(),
142
+ });
143
+ }
144
+ }
145
+ }
146
+ function waitForJob(id, timeout) {
147
+ return new Promise((resolve, reject) => {
148
+ const start = Date.now();
149
+ const check = () => {
150
+ const record = globalStore.getById(id);
151
+ if (!record) {
152
+ reject(new Error('Job not found'));
153
+ return;
154
+ }
155
+ if (record.status === 'completed') {
156
+ resolve({
157
+ status: 'completed',
158
+ duration: (record.completedAt?.getTime() ?? Date.now()) - record.createdAt.getTime(),
159
+ });
160
+ return;
161
+ }
162
+ if (record.status === 'failed') {
163
+ resolve({
164
+ status: 'failed',
165
+ error: record.error,
166
+ duration: (record.completedAt?.getTime() ?? Date.now()) - record.createdAt.getTime(),
167
+ });
168
+ return;
169
+ }
170
+ if (Date.now() - start > timeout) {
171
+ reject(new Error('Timeout waiting for job'));
172
+ return;
173
+ }
174
+ setTimeout(check, 100);
175
+ };
176
+ check();
177
+ });
178
+ }
179
+ function createLogger(name) {
180
+ const prefix = `[job:${name}]`;
181
+ return {
182
+ info: (msg, data) => console.log(prefix, msg, data ?? ''),
183
+ warn: (msg, data) => console.warn(prefix, msg, data ?? ''),
184
+ error: (msg, data) => console.error(prefix, msg, data ?? ''),
185
+ };
186
+ }
187
+ // ── Utilities ────────────────────────────────────────────────────────
188
+ /**
189
+ * Get all job records (useful for debugging).
190
+ */
191
+ export function getJobRecords() {
192
+ return globalStore['records']
193
+ ? [...globalStore['records'].values()]
194
+ : [];
195
+ }
196
+ /**
197
+ * Reset the job store (useful for testing).
198
+ */
199
+ export function resetJobs() {
200
+ globalStore = new MemoryJobStore();
201
+ handlers.clear();
202
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
2
+ export interface LogEntry {
3
+ level: LogLevel;
4
+ message: string;
5
+ data?: Record<string, unknown>;
6
+ timestamp: Date;
7
+ context?: string;
8
+ }
9
+ export interface Logger {
10
+ debug(message: string, data?: Record<string, unknown>): void;
11
+ info(message: string, data?: Record<string, unknown>): void;
12
+ warn(message: string, data?: Record<string, unknown>): void;
13
+ error(message: string, data?: Record<string, unknown>): void;
14
+ fatal(message: string, data?: Record<string, unknown>): void;
15
+ /** Create a child logger with a prefix */
16
+ child(prefix: string): Logger;
17
+ }
18
+ export interface LoggerConfig {
19
+ /** Minimum log level (default: 'info') */
20
+ level?: LogLevel;
21
+ /** Custom transport function */
22
+ transport?: (entry: LogEntry) => void;
23
+ /** Include timestamp in output (default: true) */
24
+ timestamp?: boolean;
25
+ /** Pretty print with colors (default: true in dev) */
26
+ pretty?: boolean;
27
+ }
28
+ /**
29
+ * Configure the global logger.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { configure } from '@carlos-tzin/tzin/log'
34
+ *
35
+ * configure({ level: 'debug', pretty: true })
36
+ * ```
37
+ */
38
+ export declare function configure(config: LoggerConfig): void;
39
+ /**
40
+ * Get the global logger.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * import { log } from '@carlos-tzin/tzin/log'
45
+ *
46
+ * log.info('Server started', { port: 3000 })
47
+ * ```
48
+ */
49
+ export declare function getLogger(context?: string): Logger;
50
+ /** Convenience export */
51
+ export declare const log: Logger;
package/dist/log.js ADDED
@@ -0,0 +1,94 @@
1
+ // ── Types ────────────────────────────────────────────────────────────
2
+ // ── Log Level Order ──────────────────────────────────────────────────
3
+ const LOG_LEVELS = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3,
8
+ fatal: 4,
9
+ };
10
+ // ── Colors ───────────────────────────────────────────────────────────
11
+ const COLORS = {
12
+ debug: '\x1b[36m', // cyan
13
+ info: '\x1b[32m', // green
14
+ warn: '\x1b[33m', // yellow
15
+ error: '\x1b[31m', // red
16
+ fatal: '\x1b[35m', // magenta
17
+ };
18
+ const RESET = '\x1b[0m';
19
+ // ── Default Transport ────────────────────────────────────────────────
20
+ function defaultTransport(entry, config) {
21
+ const { level, message, data, timestamp, context } = entry;
22
+ const showTimestamp = config.timestamp !== false;
23
+ const pretty = config.pretty !== false;
24
+ if (pretty) {
25
+ const color = COLORS[level];
26
+ const prefix = context ? `[${context}]` : '';
27
+ const time = showTimestamp ? `${timestamp.toISOString()} ` : '';
28
+ const dataStr = data && Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '';
29
+ console.log(`${color}${time}${prefix} ${level.toUpperCase()}${RESET} ${message}${dataStr}`);
30
+ }
31
+ else {
32
+ const json = { level, message, timestamp: timestamp.toISOString() };
33
+ if (context)
34
+ json.context = context;
35
+ if (data && Object.keys(data).length > 0)
36
+ json.data = data;
37
+ console.log(JSON.stringify(json));
38
+ }
39
+ }
40
+ // ── Logger Factory ───────────────────────────────────────────────────
41
+ function createLogger(config, context) {
42
+ const minLevel = LOG_LEVELS[config.level ?? 'info'];
43
+ const transport = config.transport ?? ((entry) => defaultTransport(entry, config));
44
+ function log(level, message, data) {
45
+ if (LOG_LEVELS[level] < minLevel)
46
+ return;
47
+ const entry = {
48
+ level,
49
+ message,
50
+ data,
51
+ timestamp: new Date(),
52
+ context,
53
+ };
54
+ transport(entry);
55
+ }
56
+ return {
57
+ debug: (msg, data) => log('debug', msg, data),
58
+ info: (msg, data) => log('info', msg, data),
59
+ warn: (msg, data) => log('warn', msg, data),
60
+ error: (msg, data) => log('error', msg, data),
61
+ fatal: (msg, data) => log('fatal', msg, data),
62
+ child: (prefix) => createLogger(config, context ? `${context}:${prefix}` : prefix),
63
+ };
64
+ }
65
+ // ── Global Logger ────────────────────────────────────────────────────
66
+ let globalConfig = {};
67
+ /**
68
+ * Configure the global logger.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { configure } from '@carlos-tzin/tzin/log'
73
+ *
74
+ * configure({ level: 'debug', pretty: true })
75
+ * ```
76
+ */
77
+ export function configure(config) {
78
+ globalConfig = config;
79
+ }
80
+ /**
81
+ * Get the global logger.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { log } from '@carlos-tzin/tzin/log'
86
+ *
87
+ * log.info('Server started', { port: 3000 })
88
+ * ```
89
+ */
90
+ export function getLogger(context) {
91
+ return createLogger(globalConfig, context);
92
+ }
93
+ /** Convenience export */
94
+ export const log = getLogger();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlos-tzin/tzin",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Contract-first TypeScript framework. Types that scale, realtime channels with presence, and an MCP server for every API.",
5
5
  "license": "MIT",
6
6
  "author": "The tzin authors",
@@ -54,6 +54,14 @@
54
54
  "./auth": {
55
55
  "types": "./dist/auth.d.ts",
56
56
  "default": "./dist/auth.js"
57
+ },
58
+ "./jobs": {
59
+ "types": "./dist/jobs.d.ts",
60
+ "default": "./dist/jobs.js"
61
+ },
62
+ "./log": {
63
+ "types": "./dist/log.d.ts",
64
+ "default": "./dist/log.js"
57
65
  }
58
66
  },
59
67
  "files": [