@getstrata/core 0.5.16 → 0.5.17

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.
@@ -13,220 +13,49 @@ var queueConfig = {
13
13
  backoffMs: Number(process.env.QUEUE_BACKOFF_MS ?? "1000")
14
14
  };
15
15
 
16
- // ../../src/core/logging/logger.ts
17
- class Logger {
18
- channel;
19
- constructor(channel = "app") {
20
- this.channel = channel;
21
- }
22
- write(level, message, context = {}) {
23
- const entry = {
24
- level,
25
- channel: this.channel,
26
- message,
27
- timestamp: new Date().toISOString(),
28
- ...context
16
+ // ../../src/core/events/eventBus.ts
17
+ class EventBus {
18
+ constructor() {}
19
+ listeners = new Map;
20
+ listen(event, listener) {
21
+ const handlers = this.listeners.get(event) ?? new Set;
22
+ handlers.add(listener);
23
+ this.listeners.set(event, handlers);
24
+ return () => {
25
+ handlers.delete(listener);
26
+ if (handlers.size === 0) {
27
+ this.listeners.delete(event);
28
+ }
29
29
  };
30
- const line = JSON.stringify(entry);
31
- if (level === "error") {
32
- console.error(line);
33
- return;
34
- }
35
- console.log(line);
36
- }
37
- debug(message, context) {
38
- this.write("debug", message, context);
39
- }
40
- info(message, context) {
41
- this.write("info", message, context);
42
- }
43
- warn(message, context) {
44
- this.write("warn", message, context);
45
- }
46
- error(message, context) {
47
- this.write("error", message, context);
48
- }
49
- }
50
- var appLogger = new Logger("app");
51
-
52
- // ../../src/bootstrap/contracts.ts
53
- class ServiceContainer {
54
- services = new Map;
55
- singletonFactories = new Map;
56
- bindings = new Map;
57
- set(key, value) {
58
- this.singletonFactories.delete(key);
59
- this.bindings.delete(key);
60
- this.services.set(key, value);
61
- return value;
62
- }
63
- singleton(key, factory) {
64
- this.bindings.delete(key);
65
- this.services.delete(key);
66
- this.singletonFactories.set(key, factory);
67
- }
68
- bind(key, factory) {
69
- this.singletonFactories.delete(key);
70
- this.services.delete(key);
71
- this.bindings.set(key, factory);
72
- }
73
- get(key) {
74
- if (this.services.has(key)) {
75
- return this.services.get(key);
76
- }
77
- const singletonFactory = this.singletonFactories.get(key);
78
- if (singletonFactory) {
79
- const value = singletonFactory(this);
80
- this.services.set(key, value);
81
- return value;
82
- }
83
- const binding = this.bindings.get(key);
84
- if (binding) {
85
- return binding(this);
86
- }
87
- throw new Error(`Service "${key}" is not registered.`);
88
- }
89
- resolve(key) {
90
- return this.get(key);
91
- }
92
- has(key) {
93
- return this.services.has(key) || this.singletonFactories.has(key) || this.bindings.has(key);
94
- }
95
- }
96
-
97
- class ConfigStore {
98
- values = new Map;
99
- set(key, value) {
100
- this.values.set(key, value);
101
- return value;
102
- }
103
- get(key) {
104
- return this.values.get(key);
105
- }
106
- require(key) {
107
- if (!this.values.has(key)) {
108
- throw new Error(`Config key "${key}" is not defined.`);
109
- }
110
- return this.values.get(key);
111
- }
112
- has(key) {
113
- return this.values.has(key);
114
- }
115
- }
116
- function getRequiredDependency(dependencies, key) {
117
- const dependency = dependencies[key];
118
- if (dependency === undefined) {
119
- throw new Error(`Required dependency "${key}" is not registered.`);
120
- }
121
- return dependency;
122
- }
123
-
124
- // ../../src/bootstrap/applicationRegistry.ts
125
- var activeContext;
126
- function requireActiveApplicationContext() {
127
- if (!activeContext) {
128
- throw new Error("The application context has not been bootstrapped.");
129
- }
130
- return activeContext;
131
- }
132
- function resolveApplicationCache() {
133
- return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
134
- }
135
-
136
- // ../../src/core/jobs/dispatchWebhookJob.ts
137
- import { createHmac } from "crypto";
138
-
139
- // ../../src/config/app.ts
140
- var appConfig = {
141
- name: "WorkHub",
142
- env: process.env.APP_ENV ?? "local",
143
- debug: (process.env.APP_DEBUG ?? "true") !== "false",
144
- url: process.env.APP_URL ?? "http://localhost:3000",
145
- apiPrefix: process.env.API_PREFIX ?? "/api/v1"
146
- };
147
-
148
- // ../../src/core/database/boundConnection.ts
149
- var boundConnectionHolder = {
150
- connection: null
151
- };
152
- function getBoundDatabaseConnection() {
153
- return boundConnectionHolder.connection;
154
- }
155
-
156
- // ../../src/core/database/connectionContext.ts
157
- import { AsyncLocalStorage } from "async_hooks";
158
- var activeConnection = new AsyncLocalStorage;
159
- function getActiveDatabaseConnection(fallback) {
160
- return activeConnection.getStore() ?? fallback;
161
- }
162
-
163
- // ../../src/core/database/queryProxy.ts
164
- var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
165
- function createDatabaseQueryProxy(pool) {
166
- function resolveDatabase() {
167
- return getActiveDatabaseConnection(pool);
168
30
  }
169
- function resolveDatabaseForProperty(property) {
170
- if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
171
- return pool;
31
+ async dispatch(event, payload) {
32
+ const handlers = this.listeners.get(event);
33
+ if (!handlers || handlers.size === 0) {
34
+ return;
172
35
  }
173
- return resolveDatabase();
174
- }
175
- return new Proxy(function database() {}, {
176
- apply(_target, _thisArg, args) {
177
- return resolveDatabase()(...args);
178
- },
179
- get(_target, property) {
180
- const connection = resolveDatabaseForProperty(property);
181
- const value = connection[property];
182
- return typeof value === "function" ? value.bind(connection) : value;
36
+ for (const handler of handlers) {
37
+ await handler(payload);
183
38
  }
184
- });
185
- }
186
-
187
- // ../../src/core/database/defaultConnection.ts
188
- var defaultPool = {
189
- connection: null
190
- };
191
- var defaultQuery = {
192
- connection: null
193
- };
194
- function registerDefaultDatabasePool(connection) {
195
- defaultPool.connection = connection;
196
- defaultQuery.connection = createDatabaseQueryProxy(connection);
197
- }
198
- function getDefaultDatabaseQuery() {
199
- if (!defaultQuery.connection) {
200
- throw new Error("Default database query handle is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
201
39
  }
202
- return defaultQuery.connection;
203
40
  }
41
+ var eventBus = new EventBus;
204
42
 
205
- // ../../src/core/database/repositoryConnection.ts
206
- function resolveRepositoryConnection() {
207
- return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
43
+ // ../../src/core/events/index.ts
44
+ function modelEventName(tableName, action) {
45
+ return `${tableName}.${action}`;
208
46
  }
209
- var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
210
- apply(_target, _thisArg, args) {
211
- return resolveRepositoryConnection()(...args);
212
- },
213
- get(_target, property) {
214
- const connection = resolveRepositoryConnection();
215
- const value = connection[property];
216
- return typeof value === "function" ? value.bind(connection) : value;
217
- }
218
- });
219
47
 
220
- // ../../src/core/queue/index.ts
221
- class Job {
222
- maxAttempts;
223
- backoffMs;
224
- priority;
48
+ // ../../src/core/pagination/index.ts
49
+ function buildPaginationMeta(input) {
50
+ const lastPage = Math.max(1, Math.ceil(input.total / input.perPage));
51
+ return {
52
+ page: input.page,
53
+ per_page: input.perPage,
54
+ total: input.total,
55
+ last_page: lastPage
56
+ };
225
57
  }
226
58
 
227
- // ../../src/core/security/safeUrl.ts
228
- import { lookup as dnsLookupImpl } from "dns/promises";
229
-
230
59
  // ../../src/core/errors/http.ts
231
60
  class HttpError extends Error {
232
61
  status;
@@ -293,563 +122,290 @@ class PreconditionFailedError extends HttpError {
293
122
  }
294
123
  }
295
124
 
296
- // ../../src/core/security/safeUrl.ts
297
- var dnsLookup = dnsLookupImpl;
298
- var BLOCKED_HOSTNAMES = new Set([
299
- "localhost",
300
- "127.0.0.1",
301
- "0.0.0.0",
302
- "::1",
303
- "metadata.google.internal"
304
- ]);
305
- function isPrivateIpv4(hostname) {
306
- const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
307
- if (!match) {
308
- return false;
125
+ // ../../src/core/database/errors.ts
126
+ function isPostgresError(error) {
127
+ return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
128
+ }
129
+ function getPostgresSqlState(error) {
130
+ if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
131
+ return error.errno;
309
132
  }
310
- const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
311
- if (octets.some((octet) => octet < 0 || octet > 255)) {
312
- return true;
313
- }
314
- const [a = 0, b = 0] = octets;
315
- if (a === 10) {
316
- return true;
133
+ if (typeof error.errno === "number") {
134
+ return String(error.errno).padStart(5, "0");
317
135
  }
318
- if (a === 127) {
319
- return true;
136
+ if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
137
+ return error.code;
320
138
  }
321
- if (a === 0) {
322
- return true;
139
+ return;
140
+ }
141
+ function mapDatabaseError(error) {
142
+ if (error instanceof HttpError) {
143
+ return error;
323
144
  }
324
- if (a === 169 && b === 254) {
325
- return true;
145
+ if (!isPostgresError(error)) {
146
+ const message = error instanceof Error ? error.message : "Database operation failed.";
147
+ return new BadRequestError(message);
326
148
  }
327
- if (a === 172 && b >= 16 && b <= 31) {
328
- return true;
149
+ const sqlState = getPostgresSqlState(error);
150
+ switch (sqlState) {
151
+ case "23505":
152
+ return new ConflictError(error.detail ?? "A record with these values already exists.", {
153
+ constraint: error.constraint
154
+ });
155
+ case "23503":
156
+ return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
157
+ constraint: error.constraint
158
+ });
159
+ case "23502":
160
+ return new BadRequestError(error.detail ?? "Required field is missing.", {
161
+ constraint: error.constraint
162
+ });
163
+ case "23514":
164
+ return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
165
+ constraint: error.constraint
166
+ });
167
+ default:
168
+ return new BadRequestError(error.message ?? "Database operation failed.", {
169
+ code: error.code,
170
+ sqlState
171
+ });
329
172
  }
330
- if (a === 192 && b === 168) {
331
- return true;
173
+ }
174
+ async function withDatabaseErrorHandling(operation) {
175
+ try {
176
+ return await operation();
177
+ } catch (error) {
178
+ throw mapDatabaseError(error);
332
179
  }
333
- return false;
334
180
  }
335
- function isBlockedHostname(hostname) {
336
- const normalized = hostname.trim().toLowerCase();
337
- if (normalized.length === 0) {
338
- return true;
181
+
182
+ // ../../src/core/database/query.ts
183
+ function quoteIdentifier(identifier) {
184
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
185
+ throw new Error(`Invalid SQL identifier: ${identifier}`);
339
186
  }
340
- if (BLOCKED_HOSTNAMES.has(normalized)) {
341
- return true;
187
+ return `"${identifier}"`;
188
+ }
189
+ function qualifyColumn(tableName, column) {
190
+ return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
191
+ }
192
+ function resolveQualifiedColumn(defaultTable, columnName) {
193
+ if (columnName.includes(".")) {
194
+ const [table, column] = columnName.split(".", 2);
195
+ if (!table || !column) {
196
+ throw new Error(`Invalid qualified column: ${columnName}`);
197
+ }
198
+ return qualifyColumn(table, column);
342
199
  }
343
- if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
344
- return true;
200
+ return qualifyColumn(defaultTable, columnName);
201
+ }
202
+ function parseQualifiedColumn(reference) {
203
+ const [table, column] = reference.split(".", 2);
204
+ if (!table || !column) {
205
+ throw new Error(`Join columns must be qualified as table.column: ${reference}`);
345
206
  }
346
- if (normalized.includes(":")) {
347
- return true;
207
+ return { table, column };
208
+ }
209
+ function normalizeDirection(direction = "ASC") {
210
+ return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
211
+ }
212
+ function isQueryOperator(value) {
213
+ return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
214
+ }
215
+ function pushParam(values, value) {
216
+ values.push(value);
217
+ return `$${values.length}`;
218
+ }
219
+ function buildInClause(column, values, params) {
220
+ if (values.length === 0) {
221
+ return "1 = 0";
348
222
  }
349
- return isPrivateIpv4(normalized);
223
+ const placeholders = values.map((value) => pushParam(params, value)).join(", ");
224
+ return `${column} IN (${placeholders})`;
350
225
  }
351
- function assertSafeOutboundUrl(rawUrl, options = {}) {
352
- let parsed;
353
- try {
354
- parsed = new URL(rawUrl);
355
- } catch {
356
- throw new BadRequestError("Webhook URL is invalid.");
226
+ function buildOperatorClauses(column, operator, params) {
227
+ const clauses = [];
228
+ if (operator.isNull === true) {
229
+ clauses.push(`${column} IS NULL`);
357
230
  }
358
- if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
359
- throw new BadRequestError("Webhook URL must use HTTPS.");
231
+ if (operator.isNull === false) {
232
+ clauses.push(`${column} IS NOT NULL`);
360
233
  }
361
- if (parsed.username || parsed.password) {
362
- throw new BadRequestError("Webhook URL must not include credentials.");
234
+ if (operator.eq !== undefined) {
235
+ if (operator.eq === null) {
236
+ clauses.push(`${column} IS NULL`);
237
+ } else {
238
+ clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
239
+ }
363
240
  }
364
- if (isBlockedHostname(parsed.hostname)) {
365
- throw new BadRequestError("Webhook URL targets a blocked host.");
241
+ if (operator.in !== undefined) {
242
+ clauses.push(buildInClause(column, operator.in, params));
366
243
  }
367
- return parsed;
368
- }
369
- function isBlockedIpAddress(address) {
370
- return isBlockedHostname(address.trim().toLowerCase());
371
- }
372
- async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
373
- const parsed = assertSafeOutboundUrl(rawUrl, options);
374
- if (options.resolveDns === false) {
375
- return parsed;
244
+ if (operator.gt !== undefined) {
245
+ clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
376
246
  }
377
- const hostname = parsed.hostname.trim().toLowerCase();
378
- const results = await dnsLookup(hostname, { all: true, verbatim: true });
379
- if (results.some((result) => isBlockedIpAddress(result.address))) {
380
- throw new BadRequestError("Webhook URL targets a blocked host.");
247
+ if (operator.gte !== undefined) {
248
+ clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
381
249
  }
382
- return parsed;
250
+ if (operator.lt !== undefined) {
251
+ clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
252
+ }
253
+ if (operator.lte !== undefined) {
254
+ clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
255
+ }
256
+ if (operator.ilike !== undefined) {
257
+ clauses.push(`${column} ILIKE ${pushParam(params, `%${operator.ilike}%`)}`);
258
+ }
259
+ if (operator.tsMatch !== undefined) {
260
+ clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
261
+ }
262
+ return clauses;
383
263
  }
384
- function setDnsLookupForTests(lookupFn) {
385
- dnsLookup = lookupFn;
264
+ function appendWhereParts(tableName, where, params) {
265
+ const clauses = [];
266
+ for (const [columnName, filterValue] of Object.entries(where)) {
267
+ if (filterValue === undefined) {
268
+ continue;
269
+ }
270
+ const column = resolveQualifiedColumn(tableName, columnName);
271
+ if (Array.isArray(filterValue)) {
272
+ clauses.push(buildInClause(column, filterValue, params));
273
+ continue;
274
+ }
275
+ if (isQueryOperator(filterValue)) {
276
+ clauses.push(...buildOperatorClauses(column, filterValue, params));
277
+ continue;
278
+ }
279
+ if (filterValue === null) {
280
+ clauses.push(`${column} IS NULL`);
281
+ continue;
282
+ }
283
+ clauses.push(`${column} = ${pushParam(params, filterValue)}`);
284
+ }
285
+ return clauses.join(" AND ");
386
286
  }
387
- function resetDnsLookupForTests() {
388
- dnsLookup = dnsLookupImpl;
287
+ function buildWhereNodeClause(tableName, node, params) {
288
+ if ("where" in node) {
289
+ return appendWhereParts(tableName, node.where, params);
290
+ }
291
+ const grouped = buildWhereGroupClause(tableName, node.group, params);
292
+ if (!grouped) {
293
+ return "";
294
+ }
295
+ return grouped.includes(" OR ") ? `(${grouped})` : grouped;
389
296
  }
390
-
391
- // ../../src/core/security/safeFetch.ts
392
- var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
393
- async function safeFetch(input, init = {}, options = {}) {
394
- const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
395
- const maxRedirects = options.maxRedirects ?? 0;
396
- const resolveDns = options.resolveDns ?? appConfig.env === "production";
397
- const urlOptions = { allowHttp: options.allowHttp, resolveDns };
398
- const controller = new AbortController;
399
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
400
- try {
401
- let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
402
- let redirectCount = 0;
403
- while (true) {
404
- const response = await fetch(currentUrl, {
405
- ...init,
406
- signal: controller.signal,
407
- redirect: "manual"
408
- });
409
- if (response.status >= 300 && response.status < 400) {
410
- const location = response.headers.get("location");
411
- if (!location || redirectCount >= maxRedirects) {
412
- return response;
413
- }
414
- currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
415
- redirectCount += 1;
416
- continue;
417
- }
418
- return response;
297
+ function buildWhereGroupClause(tableName, nodes, params) {
298
+ let result = "";
299
+ for (const node of nodes) {
300
+ const part = buildWhereNodeClause(tableName, node, params);
301
+ if (!part) {
302
+ continue;
419
303
  }
420
- } finally {
421
- clearTimeout(timeout);
304
+ if (!result) {
305
+ result = part;
306
+ continue;
307
+ }
308
+ result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
309
+ }
310
+ if (!result) {
311
+ return "";
422
312
  }
313
+ return result;
423
314
  }
424
-
425
- // ../../src/core/jobs/dispatchWebhookJob.ts
426
- class DispatchWebhookJob extends Job {
427
- maxAttempts = 3;
428
- backoffMs = 2000;
429
- async handle(payload) {
430
- const rows = await repositoryConnection`
431
- SELECT id, url, secret
432
- FROM webhook
433
- WHERE id = ${payload.webhookId} AND active = TRUE
434
- LIMIT 1
435
- `;
436
- const webhook = rows[0];
437
- if (!webhook) {
438
- return;
439
- }
440
- const body = JSON.stringify({ event: payload.event, payload: payload.payload });
441
- const signature = createHmac("sha256", webhook.secret).update(body).digest("hex");
442
- assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
443
- let responseStatus = null;
444
- let errorMessage = null;
445
- try {
446
- const response = await safeFetch(webhook.url, {
447
- method: "POST",
448
- headers: {
449
- "content-type": "application/json",
450
- "x-workhub-signature": signature
451
- },
452
- body
453
- }, { allowHttp: appConfig.env !== "production" });
454
- responseStatus = response.status;
455
- if (!response.ok) {
456
- throw new Error(`Webhook delivery failed with status ${response.status}.`);
457
- }
458
- } catch (error) {
459
- errorMessage = error instanceof Error ? error.message : String(error);
460
- await repositoryConnection`
461
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
462
- VALUES (
463
- ${webhook.id},
464
- ${payload.event},
465
- ${JSON.stringify(payload.payload)}::jsonb,
466
- ${responseStatus},
467
- ${errorMessage}
468
- )
469
- `;
470
- throw error instanceof Error ? error : new Error(errorMessage);
471
- }
472
- await repositoryConnection`
473
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
474
- VALUES (
475
- ${webhook.id},
476
- ${payload.event},
477
- ${JSON.stringify(payload.payload)}::jsonb,
478
- ${responseStatus}
479
- )
480
- `;
315
+ function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
316
+ const nodes = [];
317
+ if (Object.keys(where).length > 0) {
318
+ nodes.push({ kind: "and", where });
481
319
  }
320
+ nodes.push(...whereNodes);
321
+ const combined = buildWhereGroupClause(tableName, nodes, params);
322
+ return {
323
+ clause: combined ? ` WHERE ${combined}` : "",
324
+ params
325
+ };
482
326
  }
483
- var dispatchWebhookJob_default = DispatchWebhookJob;
484
-
485
- // ../../src/core/jobs/invalidateCacheTagsJob.ts
486
- class InvalidateCacheTagsJob extends Job {
487
- cache;
488
- constructor(cache) {
489
- super();
490
- this.cache = cache;
327
+ function resolveSoftDeleteColumn(table) {
328
+ if (!table.softDeletes) {
329
+ return null;
491
330
  }
492
- async handle(payload) {
493
- await this.cache.tags(...payload.tags).flush();
331
+ if (table.softDeletes === true) {
332
+ return "deleted_at";
494
333
  }
334
+ return table.softDeletes.column ?? "deleted_at";
495
335
  }
496
- var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
497
-
498
- // ../../src/core/queue/jobRegistry.ts
499
- class JobRegistry {
500
- constructor() {}
501
- factories = new Map;
502
- instances = new WeakMap;
503
- register(name, factory) {
504
- this.factories.set(name, factory);
505
- }
506
- resolveName(job) {
507
- return this.instances.get(job);
508
- }
509
- track(name, job) {
510
- this.instances.set(job, name);
511
- return job;
336
+ function appendSoftDeleteScope(table, options, clauses) {
337
+ const column = resolveSoftDeleteColumn(table);
338
+ if (!column) {
339
+ return;
512
340
  }
513
- create(name) {
514
- const factory = this.factories.get(name);
515
- if (!factory) {
516
- return;
517
- }
518
- return factory();
341
+ const qualifiedColumn = qualifyColumn(table.name, column);
342
+ if (options.onlyTrashed) {
343
+ clauses.push(`${qualifiedColumn} IS NOT NULL`);
344
+ return;
519
345
  }
520
- names() {
521
- return [...this.factories.keys()];
346
+ if (!options.withTrashed) {
347
+ clauses.push(`${qualifiedColumn} IS NULL`);
522
348
  }
523
349
  }
524
- var jobRegistry = new JobRegistry;
525
-
526
- // ../../src/core/events/eventBus.ts
527
- class EventBus {
528
- constructor() {}
529
- listeners = new Map;
530
- listen(event, listener) {
531
- const handlers = this.listeners.get(event) ?? new Set;
532
- handlers.add(listener);
533
- this.listeners.set(event, handlers);
534
- return () => {
535
- handlers.delete(listener);
536
- if (handlers.size === 0) {
537
- this.listeners.delete(event);
538
- }
539
- };
540
- }
541
- async dispatch(event, payload) {
542
- const handlers = this.listeners.get(event);
543
- if (!handlers || handlers.size === 0) {
544
- return;
545
- }
546
- for (const handler of handlers) {
547
- await handler(payload);
548
- }
350
+ function buildQueryWhereClause(table, options = {}, whereNodes = [], params = []) {
351
+ const { clause, params: whereParams } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes, params);
352
+ const softDeleteClauses = [];
353
+ appendSoftDeleteScope(table, options, softDeleteClauses);
354
+ if (softDeleteClauses.length === 0) {
355
+ return { clause, params: whereParams };
549
356
  }
550
- }
551
- var eventBus = new EventBus;
552
-
553
- // ../../src/core/events/index.ts
554
- function modelEventName(tableName, action) {
555
- return `${tableName}.${action}`;
556
- }
557
-
558
- // ../../src/core/pagination/index.ts
559
- function buildPaginationMeta(input) {
560
- const lastPage = Math.max(1, Math.ceil(input.total / input.perPage));
357
+ const base = clause.replace(/^ WHERE /, "");
358
+ const scope = softDeleteClauses.join(" AND ");
561
359
  return {
562
- page: input.page,
563
- per_page: input.perPage,
564
- total: input.total,
565
- last_page: lastPage
360
+ clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
361
+ params: whereParams
566
362
  };
567
363
  }
568
-
569
- // ../../src/core/database/errors.ts
570
- function isPostgresError(error) {
571
- return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
364
+ function isQueryOrder(value) {
365
+ return "column" in value;
572
366
  }
573
- function getPostgresSqlState(error) {
574
- if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
575
- return error.errno;
367
+ function normalizeOrderBy(orderBy) {
368
+ if (!orderBy) {
369
+ return [];
576
370
  }
577
- if (typeof error.errno === "number") {
578
- return String(error.errno).padStart(5, "0");
371
+ if (Array.isArray(orderBy)) {
372
+ return orderBy;
579
373
  }
580
- if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
581
- return error.code;
374
+ if (isQueryOrder(orderBy)) {
375
+ return [orderBy];
582
376
  }
583
- return;
377
+ return Object.entries(orderBy).map(([column, direction]) => ({
378
+ column,
379
+ direction
380
+ }));
584
381
  }
585
- function mapDatabaseError(error) {
586
- if (error instanceof HttpError) {
587
- return error;
588
- }
589
- if (!isPostgresError(error)) {
590
- const message = error instanceof Error ? error.message : "Database operation failed.";
591
- return new BadRequestError(message);
592
- }
593
- const sqlState = getPostgresSqlState(error);
594
- switch (sqlState) {
595
- case "23505":
596
- return new ConflictError(error.detail ?? "A record with these values already exists.", {
597
- constraint: error.constraint
598
- });
599
- case "23503":
600
- return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
601
- constraint: error.constraint
602
- });
603
- case "23502":
604
- return new BadRequestError(error.detail ?? "Required field is missing.", {
605
- constraint: error.constraint
606
- });
607
- case "23514":
608
- return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
609
- constraint: error.constraint
610
- });
611
- default:
612
- return new BadRequestError(error.message ?? "Database operation failed.", {
613
- code: error.code,
614
- sqlState
615
- });
616
- }
382
+ function buildOrderByClause(tableName, orderBy) {
383
+ const parts = normalizeOrderBy(orderBy).map(({ column, direction }) => {
384
+ return `${resolveQualifiedColumn(tableName, column)} ${normalizeDirection(direction)}`;
385
+ });
386
+ return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
617
387
  }
618
- async function withDatabaseErrorHandling(operation) {
619
- try {
620
- return await operation();
621
- } catch (error) {
622
- throw mapDatabaseError(error);
388
+ function buildGroupByClause(tableName, groupBy) {
389
+ if (!groupBy) {
390
+ return "";
623
391
  }
392
+ const columns = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((column) => String(column));
393
+ const parts = columns.map((column) => resolveQualifiedColumn(tableName, column));
394
+ return parts.length > 0 ? ` GROUP BY ${parts.join(", ")}` : "";
624
395
  }
625
-
626
- // ../../src/core/database/query.ts
627
- function quoteIdentifier(identifier) {
628
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
629
- throw new Error(`Invalid SQL identifier: ${identifier}`);
396
+ function buildHavingClause(tableName, having, params) {
397
+ if (!having) {
398
+ return "";
630
399
  }
631
- return `"${identifier}"`;
400
+ const body = appendWhereParts(tableName, having, params);
401
+ return body.length > 0 ? ` HAVING ${body}` : "";
632
402
  }
633
- function qualifyColumn(tableName, column) {
634
- return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
635
- }
636
- function resolveQualifiedColumn(defaultTable, columnName) {
637
- if (columnName.includes(".")) {
638
- const [table, column] = columnName.split(".", 2);
639
- if (!table || !column) {
640
- throw new Error(`Invalid qualified column: ${columnName}`);
641
- }
642
- return qualifyColumn(table, column);
643
- }
644
- return qualifyColumn(defaultTable, columnName);
645
- }
646
- function parseQualifiedColumn(reference) {
647
- const [table, column] = reference.split(".", 2);
648
- if (!table || !column) {
649
- throw new Error(`Join columns must be qualified as table.column: ${reference}`);
650
- }
651
- return { table, column };
652
- }
653
- function normalizeDirection(direction = "ASC") {
654
- return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
655
- }
656
- function isQueryOperator(value) {
657
- return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
658
- }
659
- function pushParam(values, value) {
660
- values.push(value);
661
- return `$${values.length}`;
662
- }
663
- function buildInClause(column, values, params) {
664
- if (values.length === 0) {
665
- return "1 = 0";
666
- }
667
- const placeholders = values.map((value) => pushParam(params, value)).join(", ");
668
- return `${column} IN (${placeholders})`;
669
- }
670
- function buildOperatorClauses(column, operator, params) {
671
- const clauses = [];
672
- if (operator.isNull === true) {
673
- clauses.push(`${column} IS NULL`);
674
- }
675
- if (operator.isNull === false) {
676
- clauses.push(`${column} IS NOT NULL`);
677
- }
678
- if (operator.eq !== undefined) {
679
- if (operator.eq === null) {
680
- clauses.push(`${column} IS NULL`);
681
- } else {
682
- clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
683
- }
684
- }
685
- if (operator.in !== undefined) {
686
- clauses.push(buildInClause(column, operator.in, params));
687
- }
688
- if (operator.gt !== undefined) {
689
- clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
690
- }
691
- if (operator.gte !== undefined) {
692
- clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
693
- }
694
- if (operator.lt !== undefined) {
695
- clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
696
- }
697
- if (operator.lte !== undefined) {
698
- clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
699
- }
700
- if (operator.ilike !== undefined) {
701
- clauses.push(`${column} ILIKE ${pushParam(params, `%${operator.ilike}%`)}`);
702
- }
703
- if (operator.tsMatch !== undefined) {
704
- clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
705
- }
706
- return clauses;
707
- }
708
- function appendWhereParts(tableName, where, params) {
709
- const clauses = [];
710
- for (const [columnName, filterValue] of Object.entries(where)) {
711
- if (filterValue === undefined) {
712
- continue;
713
- }
714
- const column = resolveQualifiedColumn(tableName, columnName);
715
- if (Array.isArray(filterValue)) {
716
- clauses.push(buildInClause(column, filterValue, params));
717
- continue;
718
- }
719
- if (isQueryOperator(filterValue)) {
720
- clauses.push(...buildOperatorClauses(column, filterValue, params));
721
- continue;
722
- }
723
- if (filterValue === null) {
724
- clauses.push(`${column} IS NULL`);
725
- continue;
726
- }
727
- clauses.push(`${column} = ${pushParam(params, filterValue)}`);
728
- }
729
- return clauses.join(" AND ");
730
- }
731
- function buildWhereNodeClause(tableName, node, params) {
732
- if ("where" in node) {
733
- return appendWhereParts(tableName, node.where, params);
734
- }
735
- const grouped = buildWhereGroupClause(tableName, node.group, params);
736
- if (!grouped) {
737
- return "";
738
- }
739
- return grouped.includes(" OR ") ? `(${grouped})` : grouped;
740
- }
741
- function buildWhereGroupClause(tableName, nodes, params) {
742
- let result = "";
743
- for (const node of nodes) {
744
- const part = buildWhereNodeClause(tableName, node, params);
745
- if (!part) {
746
- continue;
747
- }
748
- if (!result) {
749
- result = part;
750
- continue;
751
- }
752
- result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
753
- }
754
- if (!result) {
755
- return "";
756
- }
757
- return result;
758
- }
759
- function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
760
- const nodes = [];
761
- if (Object.keys(where).length > 0) {
762
- nodes.push({ kind: "and", where });
763
- }
764
- nodes.push(...whereNodes);
765
- const combined = buildWhereGroupClause(tableName, nodes, params);
766
- return {
767
- clause: combined ? ` WHERE ${combined}` : "",
768
- params
769
- };
770
- }
771
- function resolveSoftDeleteColumn(table) {
772
- if (!table.softDeletes) {
773
- return null;
774
- }
775
- if (table.softDeletes === true) {
776
- return "deleted_at";
777
- }
778
- return table.softDeletes.column ?? "deleted_at";
779
- }
780
- function appendSoftDeleteScope(table, options, clauses) {
781
- const column = resolveSoftDeleteColumn(table);
782
- if (!column) {
783
- return;
784
- }
785
- const qualifiedColumn = qualifyColumn(table.name, column);
786
- if (options.onlyTrashed) {
787
- clauses.push(`${qualifiedColumn} IS NOT NULL`);
788
- return;
789
- }
790
- if (!options.withTrashed) {
791
- clauses.push(`${qualifiedColumn} IS NULL`);
792
- }
793
- }
794
- function buildQueryWhereClause(table, options = {}, whereNodes = [], params = []) {
795
- const { clause, params: whereParams } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes, params);
796
- const softDeleteClauses = [];
797
- appendSoftDeleteScope(table, options, softDeleteClauses);
798
- if (softDeleteClauses.length === 0) {
799
- return { clause, params: whereParams };
800
- }
801
- const base = clause.replace(/^ WHERE /, "");
802
- const scope = softDeleteClauses.join(" AND ");
803
- return {
804
- clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
805
- params: whereParams
806
- };
807
- }
808
- function isQueryOrder(value) {
809
- return "column" in value;
810
- }
811
- function normalizeOrderBy(orderBy) {
812
- if (!orderBy) {
813
- return [];
814
- }
815
- if (Array.isArray(orderBy)) {
816
- return orderBy;
817
- }
818
- if (isQueryOrder(orderBy)) {
819
- return [orderBy];
820
- }
821
- return Object.entries(orderBy).map(([column, direction]) => ({
822
- column,
823
- direction
824
- }));
825
- }
826
- function buildOrderByClause(tableName, orderBy) {
827
- const parts = normalizeOrderBy(orderBy).map(({ column, direction }) => {
828
- return `${resolveQualifiedColumn(tableName, column)} ${normalizeDirection(direction)}`;
829
- });
830
- return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
831
- }
832
- function buildGroupByClause(tableName, groupBy) {
833
- if (!groupBy) {
834
- return "";
835
- }
836
- const columns = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((column) => String(column));
837
- const parts = columns.map((column) => resolveQualifiedColumn(tableName, column));
838
- return parts.length > 0 ? ` GROUP BY ${parts.join(", ")}` : "";
839
- }
840
- function buildHavingClause(tableName, having, params) {
841
- if (!having) {
842
- return "";
843
- }
844
- const body = appendWhereParts(tableName, having, params);
845
- return body.length > 0 ? ` HAVING ${body}` : "";
846
- }
847
- function buildJoinClause(joins = []) {
848
- return joins.map((join) => {
849
- const joinType = join.type === "left" ? "LEFT JOIN" : "INNER JOIN";
850
- const onClause = join.on.map(({ left, right }) => `${qualifyColumn(left.table, left.column)} = ${qualifyColumn(right.table, right.column)}`).join(" AND ");
851
- return ` ${joinType} ${quoteIdentifier(join.table)} ON ${onClause}`;
852
- }).join("");
403
+ function buildJoinClause(joins = []) {
404
+ return joins.map((join) => {
405
+ const joinType = join.type === "left" ? "LEFT JOIN" : "INNER JOIN";
406
+ const onClause = join.on.map(({ left, right }) => `${qualifyColumn(left.table, left.column)} = ${qualifyColumn(right.table, right.column)}`).join(" AND ");
407
+ return ` ${joinType} ${quoteIdentifier(join.table)} ON ${onClause}`;
408
+ }).join("");
853
409
  }
854
410
  function buildLimitClause(limit) {
855
411
  if (limit === undefined) {
@@ -1094,29 +650,101 @@ function indexMorphToRelation(children, parentsByType, relation) {
1094
650
  return result;
1095
651
  }
1096
652
 
1097
- // ../../src/core/database/whereBuilder.ts
1098
- class WhereBuilder {
1099
- nodes = [];
1100
- where(where) {
1101
- this.nodes.push({ kind: "and", where });
1102
- return this;
1103
- }
1104
- orWhere(where) {
1105
- this.nodes.push({ kind: "or", where });
1106
- return this;
653
+ // ../../src/core/database/boundConnection.ts
654
+ var boundConnectionHolder = {
655
+ connection: null
656
+ };
657
+ function getBoundDatabaseConnection() {
658
+ return boundConnectionHolder.connection;
659
+ }
660
+
661
+ // ../../src/core/database/connectionContext.ts
662
+ import { AsyncLocalStorage } from "async_hooks";
663
+ var activeConnection = new AsyncLocalStorage;
664
+ function getActiveDatabaseConnection(fallback) {
665
+ return activeConnection.getStore() ?? fallback;
666
+ }
667
+
668
+ // ../../src/core/database/queryProxy.ts
669
+ var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
670
+ function createDatabaseQueryProxy(pool) {
671
+ function resolveDatabase() {
672
+ return getActiveDatabaseConnection(pool);
1107
673
  }
1108
- whereGroup(fn) {
1109
- const nested = new WhereBuilder;
1110
- fn(nested);
1111
- if (nested.nodes.length > 0) {
1112
- this.nodes.push({ kind: "and", group: nested.nodes });
674
+ function resolveDatabaseForProperty(property) {
675
+ if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
676
+ return pool;
1113
677
  }
1114
- return this;
678
+ return resolveDatabase();
1115
679
  }
1116
- orWhereGroup(fn) {
1117
- const nested = new WhereBuilder;
1118
- fn(nested);
1119
- if (nested.nodes.length > 0) {
680
+ return new Proxy(function database() {}, {
681
+ apply(_target, _thisArg, args) {
682
+ return resolveDatabase()(...args);
683
+ },
684
+ get(_target, property) {
685
+ const connection = resolveDatabaseForProperty(property);
686
+ const value = connection[property];
687
+ return typeof value === "function" ? value.bind(connection) : value;
688
+ }
689
+ });
690
+ }
691
+
692
+ // ../../src/core/database/defaultConnection.ts
693
+ var defaultPool = {
694
+ connection: null
695
+ };
696
+ var defaultQuery = {
697
+ connection: null
698
+ };
699
+ function registerDefaultDatabasePool(connection) {
700
+ defaultPool.connection = connection;
701
+ defaultQuery.connection = createDatabaseQueryProxy(connection);
702
+ }
703
+ function getDefaultDatabaseQuery() {
704
+ if (!defaultQuery.connection) {
705
+ throw new Error("Default database query handle is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
706
+ }
707
+ return defaultQuery.connection;
708
+ }
709
+
710
+ // ../../src/core/database/repositoryConnection.ts
711
+ function resolveRepositoryConnection() {
712
+ return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
713
+ }
714
+ var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
715
+ apply(_target, _thisArg, args) {
716
+ return resolveRepositoryConnection()(...args);
717
+ },
718
+ get(_target, property) {
719
+ const connection = resolveRepositoryConnection();
720
+ const value = connection[property];
721
+ return typeof value === "function" ? value.bind(connection) : value;
722
+ }
723
+ });
724
+
725
+ // ../../src/core/database/whereBuilder.ts
726
+ class WhereBuilder {
727
+ nodes = [];
728
+ where(where) {
729
+ this.nodes.push({ kind: "and", where });
730
+ return this;
731
+ }
732
+ orWhere(where) {
733
+ this.nodes.push({ kind: "or", where });
734
+ return this;
735
+ }
736
+ whereGroup(fn) {
737
+ const nested = new WhereBuilder;
738
+ fn(nested);
739
+ if (nested.nodes.length > 0) {
740
+ this.nodes.push({ kind: "and", group: nested.nodes });
741
+ }
742
+ return this;
743
+ }
744
+ orWhereGroup(fn) {
745
+ const nested = new WhereBuilder;
746
+ fn(nested);
747
+ if (nested.nodes.length > 0) {
1120
748
  this.nodes.push({ kind: "or", group: nested.nodes });
1121
749
  }
1122
750
  return this;
@@ -2282,6 +1910,34 @@ class FailedJobService {
2282
1910
  }
2283
1911
  var failedJobService_default = FailedJobService;
2284
1912
 
1913
+ // ../../src/core/queue/jobRegistry.ts
1914
+ class JobRegistry {
1915
+ constructor() {}
1916
+ factories = new Map;
1917
+ instances = new WeakMap;
1918
+ register(name, factory) {
1919
+ this.factories.set(name, factory);
1920
+ }
1921
+ resolveName(job) {
1922
+ return this.instances.get(job);
1923
+ }
1924
+ track(name, job) {
1925
+ this.instances.set(job, name);
1926
+ return job;
1927
+ }
1928
+ create(name) {
1929
+ const factory = this.factories.get(name);
1930
+ if (!factory) {
1931
+ return;
1932
+ }
1933
+ return factory();
1934
+ }
1935
+ names() {
1936
+ return [...this.factories.keys()];
1937
+ }
1938
+ }
1939
+ var jobRegistry = new JobRegistry;
1940
+
2285
1941
  // ../../src/core/queue/jobRunner.ts
2286
1942
  async function runQueueJob(envelope, failedJobs) {
2287
1943
  const job = jobRegistry.create(envelope.name);
@@ -2312,194 +1968,538 @@ async function runQueueJob(envelope, failedJobs) {
2312
1968
  }
2313
1969
  }
2314
1970
 
2315
- // ../../src/core/queue/redisQueue.ts
2316
- var {RedisClient } = globalThis.Bun;
2317
- var QUEUE_LIST_KEY = "workhub:queue:default";
2318
- var QUEUE_HIGH_KEY = "workhub:queue:high";
2319
- var QUEUE_LOW_KEY = "workhub:queue:low";
2320
- var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
2321
- function queueKeyForPriority(priority = "default") {
2322
- switch (priority) {
2323
- case "high":
2324
- return QUEUE_HIGH_KEY;
2325
- case "low":
2326
- return QUEUE_LOW_KEY;
2327
- default:
2328
- return QUEUE_LIST_KEY;
1971
+ // ../../src/core/queue/redisQueue.ts
1972
+ var {RedisClient } = globalThis.Bun;
1973
+ var QUEUE_LIST_KEY = "workhub:queue:default";
1974
+ var QUEUE_HIGH_KEY = "workhub:queue:high";
1975
+ var QUEUE_LOW_KEY = "workhub:queue:low";
1976
+ var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
1977
+ function queueKeyForPriority(priority = "default") {
1978
+ switch (priority) {
1979
+ case "high":
1980
+ return QUEUE_HIGH_KEY;
1981
+ case "low":
1982
+ return QUEUE_LOW_KEY;
1983
+ default:
1984
+ return QUEUE_LIST_KEY;
1985
+ }
1986
+ }
1987
+ function parseQueueJobEnvelope(rawPayload) {
1988
+ let parsed;
1989
+ try {
1990
+ parsed = JSON.parse(rawPayload);
1991
+ } catch {
1992
+ console.error("[QueueWorker] Ignoring malformed queue payload");
1993
+ return null;
1994
+ }
1995
+ if (!parsed || typeof parsed !== "object") {
1996
+ console.error("[QueueWorker] Ignoring non-object queue payload");
1997
+ return null;
1998
+ }
1999
+ const envelope = parsed;
2000
+ if (typeof envelope.name !== "string" || envelope.name.length === 0) {
2001
+ console.error("[QueueWorker] Ignoring queue payload without job name");
2002
+ return null;
2003
+ }
2004
+ if (!jobRegistry.create(envelope.name)) {
2005
+ console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
2006
+ return null;
2007
+ }
2008
+ if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
2009
+ console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
2010
+ return null;
2011
+ }
2012
+ return {
2013
+ name: envelope.name,
2014
+ payload: envelope.payload ?? {},
2015
+ attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
2016
+ };
2017
+ }
2018
+
2019
+ class RedisQueue {
2020
+ client;
2021
+ constructor(redisUrl) {
2022
+ this.client = new RedisClient(redisUrl);
2023
+ }
2024
+ async dispatch(job, payload) {
2025
+ const name = jobRegistry.resolveName(job);
2026
+ if (!name) {
2027
+ throw new Error("Job is not registered with the queue worker registry.");
2028
+ }
2029
+ const envelope = {
2030
+ name,
2031
+ payload,
2032
+ attempts: 0
2033
+ };
2034
+ const queueKey = queueKeyForPriority(job.priority);
2035
+ await this.client.lpush(queueKey, JSON.stringify(envelope));
2036
+ }
2037
+ }
2038
+
2039
+ class QueueWorker {
2040
+ failedJobs;
2041
+ timeoutSeconds;
2042
+ running = false;
2043
+ stopping = false;
2044
+ client;
2045
+ constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
2046
+ this.failedJobs = failedJobs;
2047
+ this.timeoutSeconds = timeoutSeconds;
2048
+ this.client = new RedisClient(redisUrl);
2049
+ }
2050
+ requestStop() {
2051
+ this.stopping = true;
2052
+ }
2053
+ isRunning() {
2054
+ return this.running;
2055
+ }
2056
+ async processNext() {
2057
+ let result = null;
2058
+ for (const queueKey of QUEUE_KEYS) {
2059
+ result = await this.client.brpop(queueKey, 1);
2060
+ if (result) {
2061
+ break;
2062
+ }
2063
+ }
2064
+ if (!result) {
2065
+ result = await this.client.brpop(QUEUE_LIST_KEY, this.timeoutSeconds);
2066
+ }
2067
+ if (!result) {
2068
+ return false;
2069
+ }
2070
+ const [, rawPayload] = result;
2071
+ const envelope = parseQueueJobEnvelope(rawPayload);
2072
+ if (!envelope) {
2073
+ return true;
2074
+ }
2075
+ try {
2076
+ await runQueueJob(envelope, this.failedJobs);
2077
+ } catch (error) {
2078
+ console.error("[QueueWorker] Job failed:", error);
2079
+ }
2080
+ return true;
2081
+ }
2082
+ async run() {
2083
+ this.running = true;
2084
+ while (!this.stopping) {
2085
+ await this.processNext();
2086
+ }
2087
+ this.running = false;
2088
+ }
2089
+ close() {
2090
+ this.client.close();
2091
+ }
2092
+ }
2093
+
2094
+ // ../../src/core/queue/resilientQueue.ts
2095
+ class ResilientQueue {
2096
+ failedJobs;
2097
+ asyncDispatch;
2098
+ constructor(failedJobs, asyncDispatch = false) {
2099
+ this.failedJobs = failedJobs;
2100
+ this.asyncDispatch = asyncDispatch;
2101
+ }
2102
+ async dispatch(job, payload) {
2103
+ const name = jobRegistry.resolveName(job);
2104
+ if (!name) {
2105
+ throw new Error("Job is not registered with the queue worker registry.");
2106
+ }
2107
+ const envelope = {
2108
+ name,
2109
+ payload,
2110
+ attempts: 0
2111
+ };
2112
+ if (this.asyncDispatch) {
2113
+ setTimeout(() => {
2114
+ runQueueJob(envelope, this.failedJobs).catch((error) => {
2115
+ console.error("[ResilientQueue] Job failed:", error);
2116
+ });
2117
+ }, 0);
2118
+ return;
2119
+ }
2120
+ await runQueueJob(envelope, this.failedJobs);
2121
+ }
2122
+ }
2123
+
2124
+ // ../../src/core/queue/publicQueue.ts
2125
+ function createFailedJobService() {
2126
+ return new failedJobService_default(new failedJobRepository_default);
2127
+ }
2128
+ function createTrackedJob(name, job) {
2129
+ return jobRegistry.track(name, job);
2130
+ }
2131
+ function createProductionQueue(driver, options = {}) {
2132
+ options.registerJobs?.();
2133
+ const failedJobs = options.failedJobs ?? createFailedJobService();
2134
+ if (driver === "redis") {
2135
+ if (!options.redisUrl) {
2136
+ throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
2137
+ }
2138
+ return new RedisQueue(options.redisUrl);
2139
+ }
2140
+ return new ResilientQueue(failedJobs, driver === "async");
2141
+ }
2142
+ function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
2143
+ return new QueueWorker(redisUrl, failedJobs);
2144
+ }
2145
+
2146
+ // ../../src/core/jobs/dispatchWebhookJob.ts
2147
+ import { createHmac } from "crypto";
2148
+
2149
+ // ../../src/config/app.ts
2150
+ var appConfig = {
2151
+ name: "WorkHub",
2152
+ env: process.env.APP_ENV ?? "local",
2153
+ debug: (process.env.APP_DEBUG ?? "true") !== "false",
2154
+ url: process.env.APP_URL ?? "http://localhost:3000",
2155
+ apiPrefix: process.env.API_PREFIX ?? "/api/v1"
2156
+ };
2157
+
2158
+ // ../../src/core/queue/index.ts
2159
+ class Job {
2160
+ maxAttempts;
2161
+ backoffMs;
2162
+ priority;
2163
+ }
2164
+
2165
+ // ../../src/core/security/safeUrl.ts
2166
+ import { lookup as dnsLookupImpl } from "dns/promises";
2167
+ var dnsLookup = dnsLookupImpl;
2168
+ var BLOCKED_HOSTNAMES = new Set([
2169
+ "localhost",
2170
+ "127.0.0.1",
2171
+ "0.0.0.0",
2172
+ "::1",
2173
+ "metadata.google.internal"
2174
+ ]);
2175
+ function isPrivateIpv4(hostname) {
2176
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
2177
+ if (!match) {
2178
+ return false;
2179
+ }
2180
+ const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
2181
+ if (octets.some((octet) => octet < 0 || octet > 255)) {
2182
+ return true;
2183
+ }
2184
+ const [a = 0, b = 0] = octets;
2185
+ if (a === 10) {
2186
+ return true;
2187
+ }
2188
+ if (a === 127) {
2189
+ return true;
2190
+ }
2191
+ if (a === 0) {
2192
+ return true;
2193
+ }
2194
+ if (a === 169 && b === 254) {
2195
+ return true;
2196
+ }
2197
+ if (a === 172 && b >= 16 && b <= 31) {
2198
+ return true;
2199
+ }
2200
+ if (a === 192 && b === 168) {
2201
+ return true;
2202
+ }
2203
+ return false;
2204
+ }
2205
+ function isBlockedHostname(hostname) {
2206
+ const normalized = hostname.trim().toLowerCase();
2207
+ if (normalized.length === 0) {
2208
+ return true;
2209
+ }
2210
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
2211
+ return true;
2212
+ }
2213
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
2214
+ return true;
2215
+ }
2216
+ if (normalized.includes(":")) {
2217
+ return true;
2218
+ }
2219
+ return isPrivateIpv4(normalized);
2220
+ }
2221
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
2222
+ let parsed;
2223
+ try {
2224
+ parsed = new URL(rawUrl);
2225
+ } catch {
2226
+ throw new BadRequestError("Webhook URL is invalid.");
2227
+ }
2228
+ if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
2229
+ throw new BadRequestError("Webhook URL must use HTTPS.");
2230
+ }
2231
+ if (parsed.username || parsed.password) {
2232
+ throw new BadRequestError("Webhook URL must not include credentials.");
2233
+ }
2234
+ if (isBlockedHostname(parsed.hostname)) {
2235
+ throw new BadRequestError("Webhook URL targets a blocked host.");
2236
+ }
2237
+ return parsed;
2238
+ }
2239
+ function isBlockedIpAddress(address) {
2240
+ return isBlockedHostname(address.trim().toLowerCase());
2241
+ }
2242
+ async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
2243
+ const parsed = assertSafeOutboundUrl(rawUrl, options);
2244
+ if (options.resolveDns === false) {
2245
+ return parsed;
2246
+ }
2247
+ const hostname = parsed.hostname.trim().toLowerCase();
2248
+ const results = await dnsLookup(hostname, { all: true, verbatim: true });
2249
+ if (results.some((result) => isBlockedIpAddress(result.address))) {
2250
+ throw new BadRequestError("Webhook URL targets a blocked host.");
2251
+ }
2252
+ return parsed;
2253
+ }
2254
+ function setDnsLookupForTests(lookupFn) {
2255
+ dnsLookup = lookupFn;
2256
+ }
2257
+ function resetDnsLookupForTests() {
2258
+ dnsLookup = dnsLookupImpl;
2259
+ }
2260
+
2261
+ // ../../src/core/security/safeFetch.ts
2262
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
2263
+ async function safeFetch(input, init = {}, options = {}) {
2264
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
2265
+ const maxRedirects = options.maxRedirects ?? 0;
2266
+ const resolveDns = options.resolveDns ?? appConfig.env === "production";
2267
+ const urlOptions = { allowHttp: options.allowHttp, resolveDns };
2268
+ const controller = new AbortController;
2269
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
2270
+ try {
2271
+ let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
2272
+ let redirectCount = 0;
2273
+ while (true) {
2274
+ const response = await fetch(currentUrl, {
2275
+ ...init,
2276
+ signal: controller.signal,
2277
+ redirect: "manual"
2278
+ });
2279
+ if (response.status >= 300 && response.status < 400) {
2280
+ const location = response.headers.get("location");
2281
+ if (!location || redirectCount >= maxRedirects) {
2282
+ return response;
2283
+ }
2284
+ currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
2285
+ redirectCount += 1;
2286
+ continue;
2287
+ }
2288
+ return response;
2289
+ }
2290
+ } finally {
2291
+ clearTimeout(timeout);
2292
+ }
2293
+ }
2294
+
2295
+ // ../../src/core/jobs/dispatchWebhookJob.ts
2296
+ class DispatchWebhookJob extends Job {
2297
+ maxAttempts = 3;
2298
+ backoffMs = 2000;
2299
+ async handle(payload) {
2300
+ const rows = await repositoryConnection`
2301
+ SELECT id, url, secret
2302
+ FROM webhook
2303
+ WHERE id = ${payload.webhookId} AND active = TRUE
2304
+ LIMIT 1
2305
+ `;
2306
+ const webhook = rows[0];
2307
+ if (!webhook) {
2308
+ return;
2309
+ }
2310
+ const body = JSON.stringify({ event: payload.event, payload: payload.payload });
2311
+ const signature = createHmac("sha256", webhook.secret).update(body).digest("hex");
2312
+ assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
2313
+ let responseStatus = null;
2314
+ let errorMessage = null;
2315
+ try {
2316
+ const response = await safeFetch(webhook.url, {
2317
+ method: "POST",
2318
+ headers: {
2319
+ "content-type": "application/json",
2320
+ "x-workhub-signature": signature
2321
+ },
2322
+ body
2323
+ }, { allowHttp: appConfig.env !== "production" });
2324
+ responseStatus = response.status;
2325
+ if (!response.ok) {
2326
+ throw new Error(`Webhook delivery failed with status ${response.status}.`);
2327
+ }
2328
+ } catch (error) {
2329
+ errorMessage = error instanceof Error ? error.message : String(error);
2330
+ await repositoryConnection`
2331
+ INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
2332
+ VALUES (
2333
+ ${webhook.id},
2334
+ ${payload.event},
2335
+ ${JSON.stringify(payload.payload)}::jsonb,
2336
+ ${responseStatus},
2337
+ ${errorMessage}
2338
+ )
2339
+ `;
2340
+ throw error instanceof Error ? error : new Error(errorMessage);
2341
+ }
2342
+ await repositoryConnection`
2343
+ INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
2344
+ VALUES (
2345
+ ${webhook.id},
2346
+ ${payload.event},
2347
+ ${JSON.stringify(payload.payload)}::jsonb,
2348
+ ${responseStatus}
2349
+ )
2350
+ `;
2351
+ }
2352
+ }
2353
+ var dispatchWebhookJob_default = DispatchWebhookJob;
2354
+
2355
+ // ../../src/core/jobs/invalidateCacheTagsJob.ts
2356
+ class InvalidateCacheTagsJob extends Job {
2357
+ cache;
2358
+ constructor(cache) {
2359
+ super();
2360
+ this.cache = cache;
2329
2361
  }
2330
- }
2331
- function parseQueueJobEnvelope(rawPayload) {
2332
- let parsed;
2333
- try {
2334
- parsed = JSON.parse(rawPayload);
2335
- } catch {
2336
- console.error("[QueueWorker] Ignoring malformed queue payload");
2337
- return null;
2362
+ async handle(payload) {
2363
+ await this.cache.tags(...payload.tags).flush();
2338
2364
  }
2339
- if (!parsed || typeof parsed !== "object") {
2340
- console.error("[QueueWorker] Ignoring non-object queue payload");
2341
- return null;
2365
+ }
2366
+ var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
2367
+
2368
+ // ../../src/core/logging/logger.ts
2369
+ class Logger {
2370
+ channel;
2371
+ constructor(channel = "app") {
2372
+ this.channel = channel;
2342
2373
  }
2343
- const envelope = parsed;
2344
- if (typeof envelope.name !== "string" || envelope.name.length === 0) {
2345
- console.error("[QueueWorker] Ignoring queue payload without job name");
2346
- return null;
2374
+ write(level, message, context = {}) {
2375
+ const entry = {
2376
+ level,
2377
+ channel: this.channel,
2378
+ message,
2379
+ timestamp: new Date().toISOString(),
2380
+ ...context
2381
+ };
2382
+ const line = JSON.stringify(entry);
2383
+ if (level === "error") {
2384
+ console.error(line);
2385
+ return;
2386
+ }
2387
+ console.log(line);
2347
2388
  }
2348
- if (!jobRegistry.create(envelope.name)) {
2349
- console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
2350
- return null;
2389
+ debug(message, context) {
2390
+ this.write("debug", message, context);
2351
2391
  }
2352
- if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
2353
- console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
2354
- return null;
2392
+ info(message, context) {
2393
+ this.write("info", message, context);
2355
2394
  }
2356
- return {
2357
- name: envelope.name,
2358
- payload: envelope.payload ?? {},
2359
- attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
2360
- };
2361
- }
2362
-
2363
- class RedisQueue {
2364
- client;
2365
- constructor(redisUrl) {
2366
- this.client = new RedisClient(redisUrl);
2395
+ warn(message, context) {
2396
+ this.write("warn", message, context);
2367
2397
  }
2368
- async dispatch(job, payload) {
2369
- const name = jobRegistry.resolveName(job);
2370
- if (!name) {
2371
- throw new Error("Job is not registered with the queue worker registry.");
2372
- }
2373
- const envelope = {
2374
- name,
2375
- payload,
2376
- attempts: 0
2377
- };
2378
- const queueKey = queueKeyForPriority(job.priority);
2379
- await this.client.lpush(queueKey, JSON.stringify(envelope));
2398
+ error(message, context) {
2399
+ this.write("error", message, context);
2380
2400
  }
2381
2401
  }
2402
+ var appLogger = new Logger("app");
2382
2403
 
2383
- class QueueWorker {
2384
- failedJobs;
2385
- timeoutSeconds;
2386
- running = false;
2387
- stopping = false;
2388
- client;
2389
- constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
2390
- this.failedJobs = failedJobs;
2391
- this.timeoutSeconds = timeoutSeconds;
2392
- this.client = new RedisClient(redisUrl);
2404
+ // ../../src/bootstrap/contracts.ts
2405
+ class ServiceContainer {
2406
+ services = new Map;
2407
+ singletonFactories = new Map;
2408
+ bindings = new Map;
2409
+ set(key, value) {
2410
+ this.singletonFactories.delete(key);
2411
+ this.bindings.delete(key);
2412
+ this.services.set(key, value);
2413
+ return value;
2393
2414
  }
2394
- requestStop() {
2395
- this.stopping = true;
2415
+ singleton(key, factory) {
2416
+ this.bindings.delete(key);
2417
+ this.services.delete(key);
2418
+ this.singletonFactories.set(key, factory);
2396
2419
  }
2397
- isRunning() {
2398
- return this.running;
2420
+ bind(key, factory) {
2421
+ this.singletonFactories.delete(key);
2422
+ this.services.delete(key);
2423
+ this.bindings.set(key, factory);
2399
2424
  }
2400
- async processNext() {
2401
- let result = null;
2402
- for (const queueKey of QUEUE_KEYS) {
2403
- result = await this.client.brpop(queueKey, 1);
2404
- if (result) {
2405
- break;
2406
- }
2407
- }
2408
- if (!result) {
2409
- result = await this.client.brpop(QUEUE_LIST_KEY, this.timeoutSeconds);
2410
- }
2411
- if (!result) {
2412
- return false;
2425
+ get(key) {
2426
+ if (this.services.has(key)) {
2427
+ return this.services.get(key);
2413
2428
  }
2414
- const [, rawPayload] = result;
2415
- const envelope = parseQueueJobEnvelope(rawPayload);
2416
- if (!envelope) {
2417
- return true;
2429
+ const singletonFactory = this.singletonFactories.get(key);
2430
+ if (singletonFactory) {
2431
+ const value = singletonFactory(this);
2432
+ this.services.set(key, value);
2433
+ return value;
2418
2434
  }
2419
- try {
2420
- await runQueueJob(envelope, this.failedJobs);
2421
- } catch (error) {
2422
- console.error("[QueueWorker] Job failed:", error);
2435
+ const binding = this.bindings.get(key);
2436
+ if (binding) {
2437
+ return binding(this);
2423
2438
  }
2424
- return true;
2439
+ throw new Error(`Service "${key}" is not registered.`);
2425
2440
  }
2426
- async run() {
2427
- this.running = true;
2428
- while (!this.stopping) {
2429
- await this.processNext();
2430
- }
2431
- this.running = false;
2441
+ resolve(key) {
2442
+ return this.get(key);
2432
2443
  }
2433
- close() {
2434
- this.client.close();
2444
+ has(key) {
2445
+ return this.services.has(key) || this.singletonFactories.has(key) || this.bindings.has(key);
2435
2446
  }
2436
2447
  }
2437
2448
 
2438
- // ../../src/core/queue/resilientQueue.ts
2439
- class ResilientQueue {
2440
- failedJobs;
2441
- asyncDispatch;
2442
- constructor(failedJobs, asyncDispatch = false) {
2443
- this.failedJobs = failedJobs;
2444
- this.asyncDispatch = asyncDispatch;
2449
+ class ConfigStore {
2450
+ values = new Map;
2451
+ set(key, value) {
2452
+ this.values.set(key, value);
2453
+ return value;
2445
2454
  }
2446
- async dispatch(job, payload) {
2447
- const name = jobRegistry.resolveName(job);
2448
- if (!name) {
2449
- throw new Error("Job is not registered with the queue worker registry.");
2450
- }
2451
- const envelope = {
2452
- name,
2453
- payload,
2454
- attempts: 0
2455
- };
2456
- if (this.asyncDispatch) {
2457
- setTimeout(() => {
2458
- runQueueJob(envelope, this.failedJobs).catch((error) => {
2459
- console.error("[ResilientQueue] Job failed:", error);
2460
- });
2461
- }, 0);
2462
- return;
2455
+ get(key) {
2456
+ return this.values.get(key);
2457
+ }
2458
+ require(key) {
2459
+ if (!this.values.has(key)) {
2460
+ throw new Error(`Config key "${key}" is not defined.`);
2463
2461
  }
2464
- await runQueueJob(envelope, this.failedJobs);
2462
+ return this.values.get(key);
2463
+ }
2464
+ has(key) {
2465
+ return this.values.has(key);
2465
2466
  }
2466
2467
  }
2467
-
2468
- // ../../src/core/queue/publicQueue.ts
2469
- function createFailedJobService() {
2470
- return new failedJobService_default(new failedJobRepository_default);
2471
- }
2472
- function createTrackedJob(name, job) {
2473
- return jobRegistry.track(name, job);
2468
+ function getRequiredDependency(dependencies, key) {
2469
+ const dependency = dependencies[key];
2470
+ if (dependency === undefined) {
2471
+ throw new Error(`Required dependency "${key}" is not registered.`);
2472
+ }
2473
+ return dependency;
2474
2474
  }
2475
- function createProductionQueue(driver, options = {}) {
2476
- options.registerJobs?.();
2477
- const failedJobs = options.failedJobs ?? createFailedJobService();
2478
- if (driver === "redis") {
2479
- if (!options.redisUrl) {
2480
- throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
2481
- }
2482
- return new RedisQueue(options.redisUrl);
2475
+
2476
+ // ../../src/bootstrap/applicationRegistry.ts
2477
+ var activeContext;
2478
+ function requireActiveApplicationContext() {
2479
+ if (!activeContext) {
2480
+ throw new Error("The application context has not been bootstrapped.");
2483
2481
  }
2484
- return new ResilientQueue(failedJobs, driver === "async");
2482
+ return activeContext;
2485
2483
  }
2486
- function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
2487
- return new QueueWorker(redisUrl, failedJobs);
2484
+ function resolveApplicationCache() {
2485
+ return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
2488
2486
  }
2489
2487
 
2490
- // ../../src/core/queue/createAppQueue.ts
2491
- var FAILED_JOB_SERVICE_TOKEN = "core.failedJobs";
2488
+ // ../../src/bootstrap/queue/defaultJobs.ts
2492
2489
  function registerDefaultJobs() {
2493
2490
  jobRegistry.register("cache.invalidate-tags", () => {
2494
2491
  return new invalidateCacheTagsJob_default(resolveApplicationCache());
2495
2492
  });
2496
2493
  jobRegistry.register("webhook.dispatch", () => new dispatchWebhookJob_default);
2497
2494
  }
2498
- function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService()) {
2495
+
2496
+ // ../../src/core/queue/createAppQueue.ts
2497
+ var FAILED_JOB_SERVICE_TOKEN = "core.failedJobs";
2498
+ function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService(), registerJobs) {
2499
2499
  return createProductionQueue(driver, {
2500
2500
  redisUrl,
2501
2501
  failedJobs,
2502
- registerJobs: registerDefaultJobs
2502
+ registerJobs
2503
2503
  });
2504
2504
  }
2505
2505