@getstrata/bootstrap 0.2.7 → 0.2.9

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.
@@ -306,6 +306,7 @@ var db = new Proxy(function database() {}, {
306
306
  return typeof value === "function" ? value.bind(connection) : value;
307
307
  }
308
308
  });
309
+ var connection_default = db;
309
310
 
310
311
  // ../../src/modules/user/apiTokenTable.ts
311
312
  import { defineTable } from "@getstrata/core/database";
@@ -527,6 +528,11 @@ class UnauthorizedError2 extends HttpError {
527
528
  super(401, message, details);
528
529
  }
529
530
  }
531
+ class PreconditionFailedError extends HttpError {
532
+ constructor(message = "Precondition Failed", details) {
533
+ super(412, message, details);
534
+ }
535
+ }
530
536
 
531
537
  // ../../src/core/auth/authContext.ts
532
538
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
@@ -1436,590 +1442,283 @@ class InvalidateCacheTagsJob extends Job {
1436
1442
  }
1437
1443
  var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
1438
1444
 
1439
- // ../../src/core/logging/logger.ts
1440
- class Logger {
1441
- channel;
1442
- constructor(channel = "app") {
1443
- this.channel = channel;
1445
+ // ../../src/core/pagination/index.ts
1446
+ function buildPaginationMeta(input) {
1447
+ const lastPage = Math.max(1, Math.ceil(input.total / input.perPage));
1448
+ return {
1449
+ page: input.page,
1450
+ per_page: input.perPage,
1451
+ total: input.total,
1452
+ last_page: lastPage
1453
+ };
1454
+ }
1455
+
1456
+ // ../../src/core/database/errors.ts
1457
+ function isPostgresError(error) {
1458
+ return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
1459
+ }
1460
+ function getPostgresSqlState(error) {
1461
+ if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
1462
+ return error.errno;
1444
1463
  }
1445
- write(level, message, context = {}) {
1446
- const entry = {
1447
- level,
1448
- channel: this.channel,
1449
- message,
1450
- timestamp: new Date().toISOString(),
1451
- ...context
1452
- };
1453
- const line = JSON.stringify(entry);
1454
- if (level === "error") {
1455
- console.error(line);
1456
- return;
1457
- }
1458
- console.log(line);
1464
+ if (typeof error.errno === "number") {
1465
+ return String(error.errno).padStart(5, "0");
1459
1466
  }
1460
- debug(message, context) {
1461
- this.write("debug", message, context);
1467
+ if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
1468
+ return error.code;
1462
1469
  }
1463
- info(message, context) {
1464
- this.write("info", message, context);
1470
+ return;
1471
+ }
1472
+ function mapDatabaseError(error) {
1473
+ if (error instanceof HttpError) {
1474
+ return error;
1465
1475
  }
1466
- warn(message, context) {
1467
- this.write("warn", message, context);
1476
+ if (!isPostgresError(error)) {
1477
+ const message = error instanceof Error ? error.message : "Database operation failed.";
1478
+ return new BadRequestError(message);
1468
1479
  }
1469
- error(message, context) {
1470
- this.write("error", message, context);
1480
+ const sqlState = getPostgresSqlState(error);
1481
+ switch (sqlState) {
1482
+ case "23505":
1483
+ return new ConflictError(error.detail ?? "A record with these values already exists.", {
1484
+ constraint: error.constraint
1485
+ });
1486
+ case "23503":
1487
+ return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
1488
+ constraint: error.constraint
1489
+ });
1490
+ case "23502":
1491
+ return new BadRequestError(error.detail ?? "Required field is missing.", {
1492
+ constraint: error.constraint
1493
+ });
1494
+ case "23514":
1495
+ return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
1496
+ constraint: error.constraint
1497
+ });
1498
+ default:
1499
+ return new BadRequestError(error.message ?? "Database operation failed.", {
1500
+ code: error.code,
1501
+ sqlState
1502
+ });
1471
1503
  }
1472
1504
  }
1473
- var appLogger = new Logger("app");
1474
-
1475
- // ../../src/bootstrap/applicationRegistry.ts
1476
- var activeContext;
1477
- function setActiveApplicationContext(context) {
1478
- activeContext = context;
1479
- }
1480
- function requireActiveApplicationContext() {
1481
- if (!activeContext) {
1482
- throw new Error("The application context has not been bootstrapped.");
1505
+ async function withDatabaseErrorHandling(operation) {
1506
+ try {
1507
+ return await operation();
1508
+ } catch (error) {
1509
+ throw mapDatabaseError(error);
1483
1510
  }
1484
- return activeContext;
1485
- }
1486
- function resolveApplicationCache() {
1487
- return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
1488
- }
1489
- function resolveApplicationQueue() {
1490
- return requireActiveApplicationContext().container.resolve(CORE_QUEUE_TOKEN);
1491
1511
  }
1492
- function resolveApplicationAuth() {
1493
- return requireActiveApplicationContext().container.resolve(CORE_AUTH_TOKEN);
1512
+
1513
+ // ../../src/core/database/query.ts
1514
+ function quoteIdentifier(identifier) {
1515
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
1516
+ throw new Error(`Invalid SQL identifier: ${identifier}`);
1517
+ }
1518
+ return `"${identifier}"`;
1494
1519
  }
1495
- function resolveApplicationPolicyGate() {
1496
- return requireActiveApplicationContext().container.resolve(CORE_POLICY_GATE_TOKEN);
1520
+ function qualifyColumn(tableName, column) {
1521
+ return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
1497
1522
  }
1498
- function resolveApplicationConfig() {
1499
- return requireActiveApplicationContext().config;
1523
+ function resolveQualifiedColumn(defaultTable, columnName) {
1524
+ if (columnName.includes(".")) {
1525
+ const [table, column] = columnName.split(".", 2);
1526
+ if (!table || !column) {
1527
+ throw new Error(`Invalid qualified column: ${columnName}`);
1528
+ }
1529
+ return qualifyColumn(table, column);
1530
+ }
1531
+ return qualifyColumn(defaultTable, columnName);
1500
1532
  }
1501
- function resolveApplicationLogger() {
1502
- return appLogger;
1533
+ function parseQualifiedColumn(reference) {
1534
+ const [table, column] = reference.split(".", 2);
1535
+ if (!table || !column) {
1536
+ throw new Error(`Join columns must be qualified as table.column: ${reference}`);
1537
+ }
1538
+ return { table, column };
1503
1539
  }
1504
- function resolveApplicationDependencies() {
1505
- return requireActiveApplicationContext().dependencies;
1540
+ function normalizeDirection(direction = "ASC") {
1541
+ return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
1506
1542
  }
1507
-
1508
- // ../../src/core/jobs/dispatchWebhookJob.ts
1509
- import { createHmac as createHmac2 } from "crypto";
1510
-
1511
- // ../../src/core/database/boundConnection.ts
1512
- var boundConnectionHolder = {
1513
- connection: null
1514
- };
1515
- function getBoundDatabaseConnection() {
1516
- return boundConnectionHolder.connection;
1543
+ function isQueryOperator(value) {
1544
+ return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
1517
1545
  }
1518
-
1519
- // ../../src/core/database/repositoryConnection.ts
1520
- function resolveRepositoryConnection() {
1521
- return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
1546
+ function pushParam(values, value) {
1547
+ values.push(value);
1548
+ return `$${values.length}`;
1522
1549
  }
1523
- var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
1524
- apply(_target, _thisArg, args) {
1525
- return resolveRepositoryConnection()(...args);
1526
- },
1527
- get(_target, property) {
1528
- const connection = resolveRepositoryConnection();
1529
- const value = connection[property];
1530
- return typeof value === "function" ? value.bind(connection) : value;
1531
- }
1532
- });
1533
-
1534
- // ../../src/core/security/safeUrl.ts
1535
- import { lookup as dnsLookupImpl } from "dns/promises";
1536
- var dnsLookup = dnsLookupImpl;
1537
- var BLOCKED_HOSTNAMES = new Set([
1538
- "localhost",
1539
- "127.0.0.1",
1540
- "0.0.0.0",
1541
- "::1",
1542
- "metadata.google.internal"
1543
- ]);
1544
- function isPrivateIpv4(hostname) {
1545
- const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
1546
- if (!match) {
1547
- return false;
1548
- }
1549
- const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
1550
- if (octets.some((octet) => octet < 0 || octet > 255)) {
1551
- return true;
1550
+ function buildInClause(column, values, params) {
1551
+ if (values.length === 0) {
1552
+ return "1 = 0";
1552
1553
  }
1553
- const [a = 0, b = 0] = octets;
1554
- if (a === 10) {
1555
- return true;
1554
+ const placeholders = values.map((value) => pushParam(params, value)).join(", ");
1555
+ return `${column} IN (${placeholders})`;
1556
+ }
1557
+ function buildOperatorClauses(column, operator, params) {
1558
+ const clauses = [];
1559
+ if (operator.isNull === true) {
1560
+ clauses.push(`${column} IS NULL`);
1556
1561
  }
1557
- if (a === 127) {
1558
- return true;
1562
+ if (operator.isNull === false) {
1563
+ clauses.push(`${column} IS NOT NULL`);
1559
1564
  }
1560
- if (a === 0) {
1561
- return true;
1565
+ if (operator.eq !== undefined) {
1566
+ if (operator.eq === null) {
1567
+ clauses.push(`${column} IS NULL`);
1568
+ } else {
1569
+ clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
1570
+ }
1562
1571
  }
1563
- if (a === 169 && b === 254) {
1564
- return true;
1572
+ if (operator.in !== undefined) {
1573
+ clauses.push(buildInClause(column, operator.in, params));
1565
1574
  }
1566
- if (a === 172 && b >= 16 && b <= 31) {
1567
- return true;
1575
+ if (operator.gt !== undefined) {
1576
+ clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
1568
1577
  }
1569
- if (a === 192 && b === 168) {
1570
- return true;
1578
+ if (operator.gte !== undefined) {
1579
+ clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
1571
1580
  }
1572
- return false;
1573
- }
1574
- function isBlockedHostname(hostname) {
1575
- const normalized = hostname.trim().toLowerCase();
1576
- if (normalized.length === 0) {
1577
- return true;
1581
+ if (operator.lt !== undefined) {
1582
+ clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
1578
1583
  }
1579
- if (BLOCKED_HOSTNAMES.has(normalized)) {
1580
- return true;
1584
+ if (operator.lte !== undefined) {
1585
+ clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
1581
1586
  }
1582
- if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
1583
- return true;
1587
+ if (operator.ilike !== undefined) {
1588
+ clauses.push(`${column} ILIKE ${pushParam(params, `%${operator.ilike}%`)}`);
1584
1589
  }
1585
- if (normalized.includes(":")) {
1586
- return true;
1590
+ if (operator.tsMatch !== undefined) {
1591
+ clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
1587
1592
  }
1588
- return isPrivateIpv4(normalized);
1593
+ return clauses;
1589
1594
  }
1590
- function assertSafeOutboundUrl(rawUrl, options = {}) {
1591
- let parsed;
1592
- try {
1593
- parsed = new URL(rawUrl);
1594
- } catch {
1595
- throw new BadRequestError("Webhook URL is invalid.");
1596
- }
1597
- if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
1598
- throw new BadRequestError("Webhook URL must use HTTPS.");
1599
- }
1600
- if (parsed.username || parsed.password) {
1601
- throw new BadRequestError("Webhook URL must not include credentials.");
1602
- }
1603
- if (isBlockedHostname(parsed.hostname)) {
1604
- throw new BadRequestError("Webhook URL targets a blocked host.");
1595
+ function appendWhereParts(tableName, where, params) {
1596
+ const clauses = [];
1597
+ for (const [columnName, filterValue] of Object.entries(where)) {
1598
+ if (filterValue === undefined) {
1599
+ continue;
1600
+ }
1601
+ const column = resolveQualifiedColumn(tableName, columnName);
1602
+ if (Array.isArray(filterValue)) {
1603
+ clauses.push(buildInClause(column, filterValue, params));
1604
+ continue;
1605
+ }
1606
+ if (isQueryOperator(filterValue)) {
1607
+ clauses.push(...buildOperatorClauses(column, filterValue, params));
1608
+ continue;
1609
+ }
1610
+ if (filterValue === null) {
1611
+ clauses.push(`${column} IS NULL`);
1612
+ continue;
1613
+ }
1614
+ clauses.push(`${column} = ${pushParam(params, filterValue)}`);
1605
1615
  }
1606
- return parsed;
1607
- }
1608
- function isBlockedIpAddress(address) {
1609
- return isBlockedHostname(address.trim().toLowerCase());
1616
+ return clauses.join(" AND ");
1610
1617
  }
1611
- async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
1612
- const parsed = assertSafeOutboundUrl(rawUrl, options);
1613
- if (options.resolveDns === false) {
1614
- return parsed;
1618
+ function buildWhereNodeClause(tableName, node, params) {
1619
+ if ("where" in node) {
1620
+ return appendWhereParts(tableName, node.where, params);
1615
1621
  }
1616
- const hostname = parsed.hostname.trim().toLowerCase();
1617
- const results = await dnsLookup(hostname, { all: true, verbatim: true });
1618
- if (results.some((result) => isBlockedIpAddress(result.address))) {
1619
- throw new BadRequestError("Webhook URL targets a blocked host.");
1622
+ const grouped = buildWhereGroupClause(tableName, node.group, params);
1623
+ if (!grouped) {
1624
+ return "";
1620
1625
  }
1621
- return parsed;
1626
+ return grouped.includes(" OR ") ? `(${grouped})` : grouped;
1622
1627
  }
1623
-
1624
- // ../../src/core/security/safeFetch.ts
1625
- var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
1626
- async function safeFetch(input, init = {}, options = {}) {
1627
- const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
1628
- const maxRedirects = options.maxRedirects ?? 0;
1629
- const resolveDns = options.resolveDns ?? appConfig.env === "production";
1630
- const urlOptions = { allowHttp: options.allowHttp, resolveDns };
1631
- const controller = new AbortController;
1632
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
1633
- try {
1634
- let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
1635
- let redirectCount = 0;
1636
- while (true) {
1637
- const response = await fetch(currentUrl, {
1638
- ...init,
1639
- signal: controller.signal,
1640
- redirect: "manual"
1641
- });
1642
- if (response.status >= 300 && response.status < 400) {
1643
- const location = response.headers.get("location");
1644
- if (!location || redirectCount >= maxRedirects) {
1645
- return response;
1646
- }
1647
- currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
1648
- redirectCount += 1;
1649
- continue;
1650
- }
1651
- return response;
1628
+ function buildWhereGroupClause(tableName, nodes, params) {
1629
+ let result = "";
1630
+ for (const node of nodes) {
1631
+ const part = buildWhereNodeClause(tableName, node, params);
1632
+ if (!part) {
1633
+ continue;
1652
1634
  }
1653
- } finally {
1654
- clearTimeout(timeout);
1635
+ if (!result) {
1636
+ result = part;
1637
+ continue;
1638
+ }
1639
+ result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
1640
+ }
1641
+ if (!result) {
1642
+ return "";
1655
1643
  }
1644
+ return result;
1656
1645
  }
1657
-
1658
- // ../../src/core/jobs/dispatchWebhookJob.ts
1659
- class DispatchWebhookJob extends Job {
1660
- maxAttempts = 3;
1661
- backoffMs = 2000;
1662
- async handle(payload) {
1663
- const rows = await repositoryConnection`
1664
- SELECT id, url, secret
1665
- FROM webhook
1666
- WHERE id = ${payload.webhookId} AND active = TRUE
1667
- LIMIT 1
1668
- `;
1669
- const webhook = rows[0];
1670
- if (!webhook) {
1671
- return;
1672
- }
1673
- const body = JSON.stringify({ event: payload.event, payload: payload.payload });
1674
- const signature = createHmac2("sha256", webhook.secret).update(body).digest("hex");
1675
- assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
1676
- let responseStatus = null;
1677
- let errorMessage = null;
1678
- try {
1679
- const response = await safeFetch(webhook.url, {
1680
- method: "POST",
1681
- headers: {
1682
- "content-type": "application/json",
1683
- "x-workhub-signature": signature
1684
- },
1685
- body
1686
- }, { allowHttp: appConfig.env !== "production" });
1687
- responseStatus = response.status;
1688
- if (!response.ok) {
1689
- throw new Error(`Webhook delivery failed with status ${response.status}.`);
1690
- }
1691
- } catch (error) {
1692
- errorMessage = error instanceof Error ? error.message : String(error);
1693
- await repositoryConnection`
1694
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
1695
- VALUES (
1696
- ${webhook.id},
1697
- ${payload.event},
1698
- ${JSON.stringify(payload.payload)}::jsonb,
1699
- ${responseStatus},
1700
- ${errorMessage}
1701
- )
1702
- `;
1703
- throw error instanceof Error ? error : new Error(errorMessage);
1704
- }
1705
- await repositoryConnection`
1706
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
1707
- VALUES (
1708
- ${webhook.id},
1709
- ${payload.event},
1710
- ${JSON.stringify(payload.payload)}::jsonb,
1711
- ${responseStatus}
1712
- )
1713
- `;
1646
+ function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
1647
+ const nodes = [];
1648
+ if (Object.keys(where).length > 0) {
1649
+ nodes.push({ kind: "and", where });
1714
1650
  }
1651
+ nodes.push(...whereNodes);
1652
+ const combined = buildWhereGroupClause(tableName, nodes, params);
1653
+ return {
1654
+ clause: combined ? ` WHERE ${combined}` : "",
1655
+ params
1656
+ };
1715
1657
  }
1716
- var dispatchWebhookJob_default = DispatchWebhookJob;
1717
-
1718
- // ../../src/core/queue/jobRegistry.ts
1719
- class JobRegistry {
1720
- constructor() {}
1721
- factories = new Map;
1722
- instances = new WeakMap;
1723
- register(name, factory) {
1724
- this.factories.set(name, factory);
1658
+ function resolveSoftDeleteColumn(table) {
1659
+ if (!table.softDeletes) {
1660
+ return null;
1725
1661
  }
1726
- resolveName(job) {
1727
- return this.instances.get(job);
1662
+ if (table.softDeletes === true) {
1663
+ return "deleted_at";
1728
1664
  }
1729
- track(name, job) {
1730
- this.instances.set(job, name);
1731
- return job;
1665
+ return table.softDeletes.column ?? "deleted_at";
1666
+ }
1667
+ function appendSoftDeleteScope(table, options, clauses) {
1668
+ const column = resolveSoftDeleteColumn(table);
1669
+ if (!column) {
1670
+ return;
1732
1671
  }
1733
- create(name) {
1734
- const factory = this.factories.get(name);
1735
- if (!factory) {
1736
- return;
1737
- }
1738
- return factory();
1672
+ const qualifiedColumn = qualifyColumn(table.name, column);
1673
+ if (options.onlyTrashed) {
1674
+ clauses.push(`${qualifiedColumn} IS NOT NULL`);
1675
+ return;
1739
1676
  }
1740
- names() {
1741
- return [...this.factories.keys()];
1677
+ if (!options.withTrashed) {
1678
+ clauses.push(`${qualifiedColumn} IS NULL`);
1742
1679
  }
1743
1680
  }
1744
- var jobRegistry = new JobRegistry;
1745
-
1746
- // ../../src/core/pagination/index.ts
1747
- function buildPaginationMeta(input) {
1748
- const lastPage = Math.max(1, Math.ceil(input.total / input.perPage));
1681
+ function buildQueryWhereClause(table, options = {}, whereNodes = [], params = []) {
1682
+ const { clause, params: whereParams } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes, params);
1683
+ const softDeleteClauses = [];
1684
+ appendSoftDeleteScope(table, options, softDeleteClauses);
1685
+ if (softDeleteClauses.length === 0) {
1686
+ return { clause, params: whereParams };
1687
+ }
1688
+ const base = clause.replace(/^ WHERE /, "");
1689
+ const scope = softDeleteClauses.join(" AND ");
1749
1690
  return {
1750
- page: input.page,
1751
- per_page: input.perPage,
1752
- total: input.total,
1753
- last_page: lastPage
1691
+ clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
1692
+ params: whereParams
1754
1693
  };
1755
1694
  }
1756
-
1757
- // ../../src/core/database/errors.ts
1758
- function isPostgresError(error) {
1759
- return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
1695
+ function isQueryOrder(value) {
1696
+ return "column" in value;
1760
1697
  }
1761
- function getPostgresSqlState(error) {
1762
- if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
1763
- return error.errno;
1698
+ function normalizeOrderBy(orderBy) {
1699
+ if (!orderBy) {
1700
+ return [];
1764
1701
  }
1765
- if (typeof error.errno === "number") {
1766
- return String(error.errno).padStart(5, "0");
1702
+ if (Array.isArray(orderBy)) {
1703
+ return orderBy;
1767
1704
  }
1768
- if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
1769
- return error.code;
1705
+ if (isQueryOrder(orderBy)) {
1706
+ return [orderBy];
1770
1707
  }
1771
- return;
1708
+ return Object.entries(orderBy).map(([column, direction]) => ({
1709
+ column,
1710
+ direction
1711
+ }));
1772
1712
  }
1773
- function mapDatabaseError(error) {
1774
- if (error instanceof HttpError) {
1775
- return error;
1776
- }
1777
- if (!isPostgresError(error)) {
1778
- const message = error instanceof Error ? error.message : "Database operation failed.";
1779
- return new BadRequestError(message);
1780
- }
1781
- const sqlState = getPostgresSqlState(error);
1782
- switch (sqlState) {
1783
- case "23505":
1784
- return new ConflictError(error.detail ?? "A record with these values already exists.", {
1785
- constraint: error.constraint
1786
- });
1787
- case "23503":
1788
- return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
1789
- constraint: error.constraint
1790
- });
1791
- case "23502":
1792
- return new BadRequestError(error.detail ?? "Required field is missing.", {
1793
- constraint: error.constraint
1794
- });
1795
- case "23514":
1796
- return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
1797
- constraint: error.constraint
1798
- });
1799
- default:
1800
- return new BadRequestError(error.message ?? "Database operation failed.", {
1801
- code: error.code,
1802
- sqlState
1803
- });
1804
- }
1805
- }
1806
- async function withDatabaseErrorHandling(operation) {
1807
- try {
1808
- return await operation();
1809
- } catch (error) {
1810
- throw mapDatabaseError(error);
1811
- }
1812
- }
1813
-
1814
- // ../../src/core/database/query.ts
1815
- function quoteIdentifier(identifier) {
1816
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
1817
- throw new Error(`Invalid SQL identifier: ${identifier}`);
1818
- }
1819
- return `"${identifier}"`;
1820
- }
1821
- function qualifyColumn(tableName, column) {
1822
- return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
1823
- }
1824
- function resolveQualifiedColumn(defaultTable, columnName) {
1825
- if (columnName.includes(".")) {
1826
- const [table, column] = columnName.split(".", 2);
1827
- if (!table || !column) {
1828
- throw new Error(`Invalid qualified column: ${columnName}`);
1829
- }
1830
- return qualifyColumn(table, column);
1831
- }
1832
- return qualifyColumn(defaultTable, columnName);
1833
- }
1834
- function parseQualifiedColumn(reference) {
1835
- const [table, column] = reference.split(".", 2);
1836
- if (!table || !column) {
1837
- throw new Error(`Join columns must be qualified as table.column: ${reference}`);
1838
- }
1839
- return { table, column };
1840
- }
1841
- function normalizeDirection(direction = "ASC") {
1842
- return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
1843
- }
1844
- function isQueryOperator(value) {
1845
- return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
1846
- }
1847
- function pushParam(values, value) {
1848
- values.push(value);
1849
- return `$${values.length}`;
1850
- }
1851
- function buildInClause(column, values, params) {
1852
- if (values.length === 0) {
1853
- return "1 = 0";
1854
- }
1855
- const placeholders = values.map((value) => pushParam(params, value)).join(", ");
1856
- return `${column} IN (${placeholders})`;
1857
- }
1858
- function buildOperatorClauses(column, operator, params) {
1859
- const clauses = [];
1860
- if (operator.isNull === true) {
1861
- clauses.push(`${column} IS NULL`);
1862
- }
1863
- if (operator.isNull === false) {
1864
- clauses.push(`${column} IS NOT NULL`);
1865
- }
1866
- if (operator.eq !== undefined) {
1867
- if (operator.eq === null) {
1868
- clauses.push(`${column} IS NULL`);
1869
- } else {
1870
- clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
1871
- }
1872
- }
1873
- if (operator.in !== undefined) {
1874
- clauses.push(buildInClause(column, operator.in, params));
1875
- }
1876
- if (operator.gt !== undefined) {
1877
- clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
1878
- }
1879
- if (operator.gte !== undefined) {
1880
- clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
1881
- }
1882
- if (operator.lt !== undefined) {
1883
- clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
1884
- }
1885
- if (operator.lte !== undefined) {
1886
- clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
1887
- }
1888
- if (operator.ilike !== undefined) {
1889
- clauses.push(`${column} ILIKE ${pushParam(params, `%${operator.ilike}%`)}`);
1890
- }
1891
- if (operator.tsMatch !== undefined) {
1892
- clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
1893
- }
1894
- return clauses;
1895
- }
1896
- function appendWhereParts(tableName, where, params) {
1897
- const clauses = [];
1898
- for (const [columnName, filterValue] of Object.entries(where)) {
1899
- if (filterValue === undefined) {
1900
- continue;
1901
- }
1902
- const column = resolveQualifiedColumn(tableName, columnName);
1903
- if (Array.isArray(filterValue)) {
1904
- clauses.push(buildInClause(column, filterValue, params));
1905
- continue;
1906
- }
1907
- if (isQueryOperator(filterValue)) {
1908
- clauses.push(...buildOperatorClauses(column, filterValue, params));
1909
- continue;
1910
- }
1911
- if (filterValue === null) {
1912
- clauses.push(`${column} IS NULL`);
1913
- continue;
1914
- }
1915
- clauses.push(`${column} = ${pushParam(params, filterValue)}`);
1916
- }
1917
- return clauses.join(" AND ");
1918
- }
1919
- function buildWhereNodeClause(tableName, node, params) {
1920
- if ("where" in node) {
1921
- return appendWhereParts(tableName, node.where, params);
1922
- }
1923
- const grouped = buildWhereGroupClause(tableName, node.group, params);
1924
- if (!grouped) {
1925
- return "";
1926
- }
1927
- return grouped.includes(" OR ") ? `(${grouped})` : grouped;
1928
- }
1929
- function buildWhereGroupClause(tableName, nodes, params) {
1930
- let result = "";
1931
- for (const node of nodes) {
1932
- const part = buildWhereNodeClause(tableName, node, params);
1933
- if (!part) {
1934
- continue;
1935
- }
1936
- if (!result) {
1937
- result = part;
1938
- continue;
1939
- }
1940
- result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
1941
- }
1942
- if (!result) {
1943
- return "";
1944
- }
1945
- return result;
1946
- }
1947
- function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
1948
- const nodes = [];
1949
- if (Object.keys(where).length > 0) {
1950
- nodes.push({ kind: "and", where });
1951
- }
1952
- nodes.push(...whereNodes);
1953
- const combined = buildWhereGroupClause(tableName, nodes, params);
1954
- return {
1955
- clause: combined ? ` WHERE ${combined}` : "",
1956
- params
1957
- };
1958
- }
1959
- function resolveSoftDeleteColumn(table) {
1960
- if (!table.softDeletes) {
1961
- return null;
1962
- }
1963
- if (table.softDeletes === true) {
1964
- return "deleted_at";
1965
- }
1966
- return table.softDeletes.column ?? "deleted_at";
1967
- }
1968
- function appendSoftDeleteScope(table, options, clauses) {
1969
- const column = resolveSoftDeleteColumn(table);
1970
- if (!column) {
1971
- return;
1972
- }
1973
- const qualifiedColumn = qualifyColumn(table.name, column);
1974
- if (options.onlyTrashed) {
1975
- clauses.push(`${qualifiedColumn} IS NOT NULL`);
1976
- return;
1977
- }
1978
- if (!options.withTrashed) {
1979
- clauses.push(`${qualifiedColumn} IS NULL`);
1980
- }
1981
- }
1982
- function buildQueryWhereClause(table, options = {}, whereNodes = [], params = []) {
1983
- const { clause, params: whereParams } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes, params);
1984
- const softDeleteClauses = [];
1985
- appendSoftDeleteScope(table, options, softDeleteClauses);
1986
- if (softDeleteClauses.length === 0) {
1987
- return { clause, params: whereParams };
1988
- }
1989
- const base = clause.replace(/^ WHERE /, "");
1990
- const scope = softDeleteClauses.join(" AND ");
1991
- return {
1992
- clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
1993
- params: whereParams
1994
- };
1995
- }
1996
- function isQueryOrder(value) {
1997
- return "column" in value;
1998
- }
1999
- function normalizeOrderBy(orderBy) {
2000
- if (!orderBy) {
2001
- return [];
2002
- }
2003
- if (Array.isArray(orderBy)) {
2004
- return orderBy;
2005
- }
2006
- if (isQueryOrder(orderBy)) {
2007
- return [orderBy];
2008
- }
2009
- return Object.entries(orderBy).map(([column, direction]) => ({
2010
- column,
2011
- direction
2012
- }));
2013
- }
2014
- function buildOrderByClause(tableName, orderBy) {
2015
- const parts = normalizeOrderBy(orderBy).map(({ column, direction }) => {
2016
- return `${resolveQualifiedColumn(tableName, column)} ${normalizeDirection(direction)}`;
2017
- });
2018
- return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
2019
- }
2020
- function buildGroupByClause(tableName, groupBy) {
2021
- if (!groupBy) {
2022
- return "";
1713
+ function buildOrderByClause(tableName, orderBy) {
1714
+ const parts = normalizeOrderBy(orderBy).map(({ column, direction }) => {
1715
+ return `${resolveQualifiedColumn(tableName, column)} ${normalizeDirection(direction)}`;
1716
+ });
1717
+ return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
1718
+ }
1719
+ function buildGroupByClause(tableName, groupBy) {
1720
+ if (!groupBy) {
1721
+ return "";
2023
1722
  }
2024
1723
  const columns = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((column) => String(column));
2025
1724
  const parts = columns.map((column) => resolveQualifiedColumn(tableName, column));
@@ -2282,15 +1981,38 @@ function indexMorphToRelation(children, parentsByType, relation) {
2282
1981
  return result;
2283
1982
  }
2284
1983
 
2285
- // ../../src/core/database/whereBuilder.ts
2286
- class WhereBuilder {
2287
- nodes = [];
2288
- where(where) {
2289
- this.nodes.push({ kind: "and", where });
2290
- return this;
2291
- }
2292
- orWhere(where) {
2293
- this.nodes.push({ kind: "or", where });
1984
+ // ../../src/core/database/boundConnection.ts
1985
+ var boundConnectionHolder = {
1986
+ connection: null
1987
+ };
1988
+ function getBoundDatabaseConnection() {
1989
+ return boundConnectionHolder.connection;
1990
+ }
1991
+
1992
+ // ../../src/core/database/repositoryConnection.ts
1993
+ function resolveRepositoryConnection() {
1994
+ return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
1995
+ }
1996
+ var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
1997
+ apply(_target, _thisArg, args) {
1998
+ return resolveRepositoryConnection()(...args);
1999
+ },
2000
+ get(_target, property) {
2001
+ const connection = resolveRepositoryConnection();
2002
+ const value = connection[property];
2003
+ return typeof value === "function" ? value.bind(connection) : value;
2004
+ }
2005
+ });
2006
+
2007
+ // ../../src/core/database/whereBuilder.ts
2008
+ class WhereBuilder {
2009
+ nodes = [];
2010
+ where(where) {
2011
+ this.nodes.push({ kind: "and", where });
2012
+ return this;
2013
+ }
2014
+ orWhere(where) {
2015
+ this.nodes.push({ kind: "or", where });
2294
2016
  return this;
2295
2017
  }
2296
2018
  whereGroup(fn) {
@@ -3424,179 +3146,465 @@ class FailedJobRepository extends baseRepository_default {
3424
3146
  super(failedJobTable);
3425
3147
  }
3426
3148
  }
3427
- var failedJobRepository_default = FailedJobRepository;
3149
+ var failedJobRepository_default = FailedJobRepository;
3150
+
3151
+ // ../../src/core/queue/failedJobService.ts
3152
+ class FailedJobService {
3153
+ repository;
3154
+ constructor(repository) {
3155
+ this.repository = repository;
3156
+ }
3157
+ async recordFailure(input) {
3158
+ return await this.repository.create({
3159
+ job_name: input.jobName,
3160
+ payload: input.payload,
3161
+ exception: input.exception,
3162
+ failed_at: new Date
3163
+ });
3164
+ }
3165
+ listRecent(limit = 50) {
3166
+ return this.repository.findAll({
3167
+ limit,
3168
+ orderBy: { column: "failed_at", direction: "DESC" }
3169
+ });
3170
+ }
3171
+ async retry(id) {
3172
+ const failedJob = await this.repository.findByIdOrThrow(id, (jobId) => new Error(`Failed job ${jobId} not found.`));
3173
+ await this.repository.deleteById(id);
3174
+ return failedJob;
3175
+ }
3176
+ async delete(id) {
3177
+ const deleted = await this.repository.deleteById(id);
3178
+ if (!deleted) {
3179
+ throw new Error(`Failed job ${id} not found.`);
3180
+ }
3181
+ }
3182
+ async flush() {
3183
+ const jobs = await this.repository.findAll();
3184
+ let deleted = 0;
3185
+ for (const job of jobs) {
3186
+ if (await this.repository.deleteById(job.id)) {
3187
+ deleted += 1;
3188
+ }
3189
+ }
3190
+ return deleted;
3191
+ }
3192
+ }
3193
+ var failedJobService_default = FailedJobService;
3194
+
3195
+ // ../../src/core/queue/jobRegistry.ts
3196
+ class JobRegistry {
3197
+ constructor() {}
3198
+ factories = new Map;
3199
+ instances = new WeakMap;
3200
+ register(name, factory) {
3201
+ this.factories.set(name, factory);
3202
+ }
3203
+ resolveName(job) {
3204
+ return this.instances.get(job);
3205
+ }
3206
+ track(name, job) {
3207
+ this.instances.set(job, name);
3208
+ return job;
3209
+ }
3210
+ create(name) {
3211
+ const factory = this.factories.get(name);
3212
+ if (!factory) {
3213
+ return;
3214
+ }
3215
+ return factory();
3216
+ }
3217
+ names() {
3218
+ return [...this.factories.keys()];
3219
+ }
3220
+ }
3221
+ var jobRegistry = new JobRegistry;
3222
+
3223
+ // ../../src/core/queue/jobRunner.ts
3224
+ async function runQueueJob(envelope, failedJobs) {
3225
+ const job = jobRegistry.create(envelope.name);
3226
+ if (!job) {
3227
+ throw new Error(`Unknown job "${envelope.name}".`);
3228
+ }
3229
+ const attempts = envelope.attempts ?? 0;
3230
+ try {
3231
+ await job.handle(envelope.payload);
3232
+ } catch (error) {
3233
+ const nextAttempt = attempts + 1;
3234
+ const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
3235
+ if (nextAttempt < maxAttempts) {
3236
+ const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
3237
+ await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
3238
+ await runQueueJob({
3239
+ ...envelope,
3240
+ attempts: nextAttempt
3241
+ }, failedJobs);
3242
+ return;
3243
+ }
3244
+ await failedJobs.recordFailure({
3245
+ jobName: envelope.name,
3246
+ payload: envelope.payload,
3247
+ exception: error instanceof Error ? error.stack ?? error.message : String(error)
3248
+ });
3249
+ throw error;
3250
+ }
3251
+ }
3252
+
3253
+ // ../../src/core/queue/redisQueue.ts
3254
+ var {RedisClient: RedisClient2 } = globalThis.Bun;
3255
+ var QUEUE_LIST_KEY = "workhub:queue:default";
3256
+ var QUEUE_HIGH_KEY = "workhub:queue:high";
3257
+ var QUEUE_LOW_KEY = "workhub:queue:low";
3258
+ function queueKeyForPriority(priority = "default") {
3259
+ switch (priority) {
3260
+ case "high":
3261
+ return QUEUE_HIGH_KEY;
3262
+ case "low":
3263
+ return QUEUE_LOW_KEY;
3264
+ default:
3265
+ return QUEUE_LIST_KEY;
3266
+ }
3267
+ }
3268
+ class RedisQueue {
3269
+ client;
3270
+ constructor(redisUrl) {
3271
+ this.client = new RedisClient2(redisUrl);
3272
+ }
3273
+ async dispatch(job, payload) {
3274
+ const name = jobRegistry.resolveName(job);
3275
+ if (!name) {
3276
+ throw new Error("Job is not registered with the queue worker registry.");
3277
+ }
3278
+ const envelope = {
3279
+ name,
3280
+ payload,
3281
+ attempts: 0
3282
+ };
3283
+ const queueKey = queueKeyForPriority(job.priority);
3284
+ await this.client.lpush(queueKey, JSON.stringify(envelope));
3285
+ }
3286
+ }
3287
+
3288
+ // ../../src/core/queue/resilientQueue.ts
3289
+ class ResilientQueue {
3290
+ failedJobs;
3291
+ asyncDispatch;
3292
+ constructor(failedJobs, asyncDispatch = false) {
3293
+ this.failedJobs = failedJobs;
3294
+ this.asyncDispatch = asyncDispatch;
3295
+ }
3296
+ async dispatch(job, payload) {
3297
+ const name = jobRegistry.resolveName(job);
3298
+ if (!name) {
3299
+ throw new Error("Job is not registered with the queue worker registry.");
3300
+ }
3301
+ const envelope = {
3302
+ name,
3303
+ payload,
3304
+ attempts: 0
3305
+ };
3306
+ if (this.asyncDispatch) {
3307
+ setTimeout(() => {
3308
+ runQueueJob(envelope, this.failedJobs).catch((error) => {
3309
+ console.error("[ResilientQueue] Job failed:", error);
3310
+ });
3311
+ }, 0);
3312
+ return;
3313
+ }
3314
+ await runQueueJob(envelope, this.failedJobs);
3315
+ }
3316
+ }
3317
+
3318
+ // ../../src/core/queue/publicQueue.ts
3319
+ function createFailedJobService() {
3320
+ return new failedJobService_default(new failedJobRepository_default);
3321
+ }
3322
+ function createTrackedJob(name, job) {
3323
+ return jobRegistry.track(name, job);
3324
+ }
3325
+ function createProductionQueue(driver, options = {}) {
3326
+ options.registerJobs?.();
3327
+ const failedJobs = options.failedJobs ?? createFailedJobService();
3328
+ if (driver === "redis") {
3329
+ if (!options.redisUrl) {
3330
+ throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
3331
+ }
3332
+ return new RedisQueue(options.redisUrl);
3333
+ }
3334
+ return new ResilientQueue(failedJobs, driver === "async");
3335
+ }
3336
+
3337
+ // ../../src/core/jobs/dispatchWebhookJob.ts
3338
+ import { createHmac as createHmac2 } from "crypto";
3339
+
3340
+ // ../../src/core/security/safeUrl.ts
3341
+ import { lookup as dnsLookupImpl } from "dns/promises";
3342
+ var dnsLookup = dnsLookupImpl;
3343
+ var BLOCKED_HOSTNAMES = new Set([
3344
+ "localhost",
3345
+ "127.0.0.1",
3346
+ "0.0.0.0",
3347
+ "::1",
3348
+ "metadata.google.internal"
3349
+ ]);
3350
+ function isPrivateIpv4(hostname) {
3351
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
3352
+ if (!match) {
3353
+ return false;
3354
+ }
3355
+ const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
3356
+ if (octets.some((octet) => octet < 0 || octet > 255)) {
3357
+ return true;
3358
+ }
3359
+ const [a = 0, b = 0] = octets;
3360
+ if (a === 10) {
3361
+ return true;
3362
+ }
3363
+ if (a === 127) {
3364
+ return true;
3365
+ }
3366
+ if (a === 0) {
3367
+ return true;
3368
+ }
3369
+ if (a === 169 && b === 254) {
3370
+ return true;
3371
+ }
3372
+ if (a === 172 && b >= 16 && b <= 31) {
3373
+ return true;
3374
+ }
3375
+ if (a === 192 && b === 168) {
3376
+ return true;
3377
+ }
3378
+ return false;
3379
+ }
3380
+ function isBlockedHostname(hostname) {
3381
+ const normalized = hostname.trim().toLowerCase();
3382
+ if (normalized.length === 0) {
3383
+ return true;
3384
+ }
3385
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
3386
+ return true;
3387
+ }
3388
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
3389
+ return true;
3390
+ }
3391
+ if (normalized.includes(":")) {
3392
+ return true;
3393
+ }
3394
+ return isPrivateIpv4(normalized);
3395
+ }
3396
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
3397
+ let parsed;
3398
+ try {
3399
+ parsed = new URL(rawUrl);
3400
+ } catch {
3401
+ throw new BadRequestError("Webhook URL is invalid.");
3402
+ }
3403
+ if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
3404
+ throw new BadRequestError("Webhook URL must use HTTPS.");
3405
+ }
3406
+ if (parsed.username || parsed.password) {
3407
+ throw new BadRequestError("Webhook URL must not include credentials.");
3408
+ }
3409
+ if (isBlockedHostname(parsed.hostname)) {
3410
+ throw new BadRequestError("Webhook URL targets a blocked host.");
3411
+ }
3412
+ return parsed;
3413
+ }
3414
+ function isBlockedIpAddress(address) {
3415
+ return isBlockedHostname(address.trim().toLowerCase());
3416
+ }
3417
+ async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
3418
+ const parsed = assertSafeOutboundUrl(rawUrl, options);
3419
+ if (options.resolveDns === false) {
3420
+ return parsed;
3421
+ }
3422
+ const hostname = parsed.hostname.trim().toLowerCase();
3423
+ const results = await dnsLookup(hostname, { all: true, verbatim: true });
3424
+ if (results.some((result) => isBlockedIpAddress(result.address))) {
3425
+ throw new BadRequestError("Webhook URL targets a blocked host.");
3426
+ }
3427
+ return parsed;
3428
+ }
3429
+
3430
+ // ../../src/core/security/safeFetch.ts
3431
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
3432
+ async function safeFetch(input, init = {}, options = {}) {
3433
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3434
+ const maxRedirects = options.maxRedirects ?? 0;
3435
+ const resolveDns = options.resolveDns ?? appConfig.env === "production";
3436
+ const urlOptions = { allowHttp: options.allowHttp, resolveDns };
3437
+ const controller = new AbortController;
3438
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
3439
+ try {
3440
+ let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
3441
+ let redirectCount = 0;
3442
+ while (true) {
3443
+ const response = await fetch(currentUrl, {
3444
+ ...init,
3445
+ signal: controller.signal,
3446
+ redirect: "manual"
3447
+ });
3448
+ if (response.status >= 300 && response.status < 400) {
3449
+ const location = response.headers.get("location");
3450
+ if (!location || redirectCount >= maxRedirects) {
3451
+ return response;
3452
+ }
3453
+ currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
3454
+ redirectCount += 1;
3455
+ continue;
3456
+ }
3457
+ return response;
3458
+ }
3459
+ } finally {
3460
+ clearTimeout(timeout);
3461
+ }
3462
+ }
3428
3463
 
3429
- // ../../src/core/queue/failedJobService.ts
3430
- class FailedJobService {
3431
- repository;
3432
- constructor(repository) {
3433
- this.repository = repository;
3434
- }
3435
- async recordFailure(input) {
3436
- return await this.repository.create({
3437
- job_name: input.jobName,
3438
- payload: input.payload,
3439
- exception: input.exception,
3440
- failed_at: new Date
3441
- });
3442
- }
3443
- listRecent(limit = 50) {
3444
- return this.repository.findAll({
3445
- limit,
3446
- orderBy: { column: "failed_at", direction: "DESC" }
3447
- });
3448
- }
3449
- async retry(id) {
3450
- const failedJob = await this.repository.findByIdOrThrow(id, (jobId) => new Error(`Failed job ${jobId} not found.`));
3451
- await this.repository.deleteById(id);
3452
- return failedJob;
3453
- }
3454
- async delete(id) {
3455
- const deleted = await this.repository.deleteById(id);
3456
- if (!deleted) {
3457
- throw new Error(`Failed job ${id} not found.`);
3464
+ // ../../src/core/jobs/dispatchWebhookJob.ts
3465
+ class DispatchWebhookJob extends Job {
3466
+ maxAttempts = 3;
3467
+ backoffMs = 2000;
3468
+ async handle(payload) {
3469
+ const rows = await repositoryConnection`
3470
+ SELECT id, url, secret
3471
+ FROM webhook
3472
+ WHERE id = ${payload.webhookId} AND active = TRUE
3473
+ LIMIT 1
3474
+ `;
3475
+ const webhook = rows[0];
3476
+ if (!webhook) {
3477
+ return;
3458
3478
  }
3459
- }
3460
- async flush() {
3461
- const jobs = await this.repository.findAll();
3462
- let deleted = 0;
3463
- for (const job of jobs) {
3464
- if (await this.repository.deleteById(job.id)) {
3465
- deleted += 1;
3479
+ const body = JSON.stringify({ event: payload.event, payload: payload.payload });
3480
+ const signature = createHmac2("sha256", webhook.secret).update(body).digest("hex");
3481
+ assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
3482
+ let responseStatus = null;
3483
+ let errorMessage = null;
3484
+ try {
3485
+ const response = await safeFetch(webhook.url, {
3486
+ method: "POST",
3487
+ headers: {
3488
+ "content-type": "application/json",
3489
+ "x-workhub-signature": signature
3490
+ },
3491
+ body
3492
+ }, { allowHttp: appConfig.env !== "production" });
3493
+ responseStatus = response.status;
3494
+ if (!response.ok) {
3495
+ throw new Error(`Webhook delivery failed with status ${response.status}.`);
3466
3496
  }
3497
+ } catch (error) {
3498
+ errorMessage = error instanceof Error ? error.message : String(error);
3499
+ await repositoryConnection`
3500
+ INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
3501
+ VALUES (
3502
+ ${webhook.id},
3503
+ ${payload.event},
3504
+ ${JSON.stringify(payload.payload)}::jsonb,
3505
+ ${responseStatus},
3506
+ ${errorMessage}
3507
+ )
3508
+ `;
3509
+ throw error instanceof Error ? error : new Error(errorMessage);
3467
3510
  }
3468
- return deleted;
3511
+ await repositoryConnection`
3512
+ INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
3513
+ VALUES (
3514
+ ${webhook.id},
3515
+ ${payload.event},
3516
+ ${JSON.stringify(payload.payload)}::jsonb,
3517
+ ${responseStatus}
3518
+ )
3519
+ `;
3469
3520
  }
3470
3521
  }
3471
- var failedJobService_default = FailedJobService;
3522
+ var dispatchWebhookJob_default = DispatchWebhookJob;
3472
3523
 
3473
- // ../../src/core/queue/jobRunner.ts
3474
- async function runQueueJob(envelope, failedJobs) {
3475
- const job = jobRegistry.create(envelope.name);
3476
- if (!job) {
3477
- throw new Error(`Unknown job "${envelope.name}".`);
3524
+ // ../../src/core/logging/logger.ts
3525
+ class Logger {
3526
+ channel;
3527
+ constructor(channel = "app") {
3528
+ this.channel = channel;
3478
3529
  }
3479
- const attempts = envelope.attempts ?? 0;
3480
- try {
3481
- await job.handle(envelope.payload);
3482
- } catch (error) {
3483
- const nextAttempt = attempts + 1;
3484
- const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
3485
- if (nextAttempt < maxAttempts) {
3486
- const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
3487
- await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
3488
- await runQueueJob({
3489
- ...envelope,
3490
- attempts: nextAttempt
3491
- }, failedJobs);
3530
+ write(level, message, context = {}) {
3531
+ const entry = {
3532
+ level,
3533
+ channel: this.channel,
3534
+ message,
3535
+ timestamp: new Date().toISOString(),
3536
+ ...context
3537
+ };
3538
+ const line = JSON.stringify(entry);
3539
+ if (level === "error") {
3540
+ console.error(line);
3492
3541
  return;
3493
3542
  }
3494
- await failedJobs.recordFailure({
3495
- jobName: envelope.name,
3496
- payload: envelope.payload,
3497
- exception: error instanceof Error ? error.stack ?? error.message : String(error)
3498
- });
3499
- throw error;
3543
+ console.log(line);
3500
3544
  }
3501
- }
3502
-
3503
- // ../../src/core/queue/redisQueue.ts
3504
- var {RedisClient: RedisClient2 } = globalThis.Bun;
3505
- var QUEUE_LIST_KEY = "workhub:queue:default";
3506
- var QUEUE_HIGH_KEY = "workhub:queue:high";
3507
- var QUEUE_LOW_KEY = "workhub:queue:low";
3508
- function queueKeyForPriority(priority = "default") {
3509
- switch (priority) {
3510
- case "high":
3511
- return QUEUE_HIGH_KEY;
3512
- case "low":
3513
- return QUEUE_LOW_KEY;
3514
- default:
3515
- return QUEUE_LIST_KEY;
3545
+ debug(message, context) {
3546
+ this.write("debug", message, context);
3516
3547
  }
3517
- }
3518
- class RedisQueue {
3519
- client;
3520
- constructor(redisUrl) {
3521
- this.client = new RedisClient2(redisUrl);
3548
+ info(message, context) {
3549
+ this.write("info", message, context);
3522
3550
  }
3523
- async dispatch(job, payload) {
3524
- const name = jobRegistry.resolveName(job);
3525
- if (!name) {
3526
- throw new Error("Job is not registered with the queue worker registry.");
3527
- }
3528
- const envelope = {
3529
- name,
3530
- payload,
3531
- attempts: 0
3532
- };
3533
- const queueKey = queueKeyForPriority(job.priority);
3534
- await this.client.lpush(queueKey, JSON.stringify(envelope));
3551
+ warn(message, context) {
3552
+ this.write("warn", message, context);
3553
+ }
3554
+ error(message, context) {
3555
+ this.write("error", message, context);
3535
3556
  }
3536
3557
  }
3558
+ var appLogger = new Logger("app");
3537
3559
 
3538
- // ../../src/core/queue/resilientQueue.ts
3539
- class ResilientQueue {
3540
- failedJobs;
3541
- asyncDispatch;
3542
- constructor(failedJobs, asyncDispatch = false) {
3543
- this.failedJobs = failedJobs;
3544
- this.asyncDispatch = asyncDispatch;
3545
- }
3546
- async dispatch(job, payload) {
3547
- const name = jobRegistry.resolveName(job);
3548
- if (!name) {
3549
- throw new Error("Job is not registered with the queue worker registry.");
3550
- }
3551
- const envelope = {
3552
- name,
3553
- payload,
3554
- attempts: 0
3555
- };
3556
- if (this.asyncDispatch) {
3557
- setTimeout(() => {
3558
- runQueueJob(envelope, this.failedJobs).catch((error) => {
3559
- console.error("[ResilientQueue] Job failed:", error);
3560
- });
3561
- }, 0);
3562
- return;
3563
- }
3564
- await runQueueJob(envelope, this.failedJobs);
3560
+ // ../../src/bootstrap/applicationRegistry.ts
3561
+ var activeContext;
3562
+ function setActiveApplicationContext(context) {
3563
+ activeContext = context;
3564
+ }
3565
+ function requireActiveApplicationContext() {
3566
+ if (!activeContext) {
3567
+ throw new Error("The application context has not been bootstrapped.");
3565
3568
  }
3569
+ return activeContext;
3566
3570
  }
3567
-
3568
- // ../../src/core/queue/publicQueue.ts
3569
- function createFailedJobService() {
3570
- return new failedJobService_default(new failedJobRepository_default);
3571
+ function resolveApplicationCache() {
3572
+ return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
3571
3573
  }
3572
- function createTrackedJob(name, job) {
3573
- return jobRegistry.track(name, job);
3574
+ function resolveApplicationQueue() {
3575
+ return requireActiveApplicationContext().container.resolve(CORE_QUEUE_TOKEN);
3574
3576
  }
3575
- function createProductionQueue(driver, options = {}) {
3576
- options.registerJobs?.();
3577
- const failedJobs = options.failedJobs ?? createFailedJobService();
3578
- if (driver === "redis") {
3579
- if (!options.redisUrl) {
3580
- throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
3581
- }
3582
- return new RedisQueue(options.redisUrl);
3583
- }
3584
- return new ResilientQueue(failedJobs, driver === "async");
3577
+ function resolveApplicationAuth() {
3578
+ return requireActiveApplicationContext().container.resolve(CORE_AUTH_TOKEN);
3579
+ }
3580
+ function resolveApplicationPolicyGate() {
3581
+ return requireActiveApplicationContext().container.resolve(CORE_POLICY_GATE_TOKEN);
3582
+ }
3583
+ function resolveApplicationConfig() {
3584
+ return requireActiveApplicationContext().config;
3585
+ }
3586
+ function resolveApplicationLogger() {
3587
+ return appLogger;
3588
+ }
3589
+ function resolveApplicationDependencies() {
3590
+ return requireActiveApplicationContext().dependencies;
3585
3591
  }
3586
3592
 
3587
- // ../../src/core/queue/createAppQueue.ts
3588
- var FAILED_JOB_SERVICE_TOKEN = "core.failedJobs";
3593
+ // ../../src/bootstrap/queue/defaultJobs.ts
3589
3594
  function registerDefaultJobs() {
3590
3595
  jobRegistry.register("cache.invalidate-tags", () => {
3591
3596
  return new invalidateCacheTagsJob_default(resolveApplicationCache());
3592
3597
  });
3593
3598
  jobRegistry.register("webhook.dispatch", () => new dispatchWebhookJob_default);
3594
3599
  }
3595
- function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService()) {
3600
+
3601
+ // ../../src/core/queue/createAppQueue.ts
3602
+ var FAILED_JOB_SERVICE_TOKEN = "core.failedJobs";
3603
+ function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService(), registerJobs) {
3596
3604
  return createProductionQueue(driver, {
3597
3605
  redisUrl,
3598
3606
  failedJobs,
3599
- registerJobs: registerDefaultJobs
3607
+ registerJobs
3600
3608
  });
3601
3609
  }
3602
3610
 
@@ -3704,7 +3712,7 @@ var queueProvider = {
3704
3712
  config.set("queue.driver", driver);
3705
3713
  const failedJobs = createFailedJobService();
3706
3714
  container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
3707
- container.set(CORE_QUEUE_TOKEN, createAppQueue(driver, config.get(REDIS_URL_CONFIG_KEY) ?? process.env.REDIS_URL, failedJobs));
3715
+ container.set(CORE_QUEUE_TOKEN, createAppQueue(driver, config.get(REDIS_URL_CONFIG_KEY) ?? process.env.REDIS_URL, failedJobs, registerDefaultJobs));
3708
3716
  }
3709
3717
  };
3710
3718
  var queue_default = queueProvider;