@getstrata/bootstrap 0.2.8 → 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";
@@ -1441,590 +1442,283 @@ class InvalidateCacheTagsJob extends Job {
1441
1442
  }
1442
1443
  var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
1443
1444
 
1444
- // ../../src/core/logging/logger.ts
1445
- class Logger {
1446
- channel;
1447
- constructor(channel = "app") {
1448
- 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;
1449
1463
  }
1450
- write(level, message, context = {}) {
1451
- const entry = {
1452
- level,
1453
- channel: this.channel,
1454
- message,
1455
- timestamp: new Date().toISOString(),
1456
- ...context
1457
- };
1458
- const line = JSON.stringify(entry);
1459
- if (level === "error") {
1460
- console.error(line);
1461
- return;
1462
- }
1463
- console.log(line);
1464
+ if (typeof error.errno === "number") {
1465
+ return String(error.errno).padStart(5, "0");
1464
1466
  }
1465
- debug(message, context) {
1466
- this.write("debug", message, context);
1467
+ if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
1468
+ return error.code;
1467
1469
  }
1468
- info(message, context) {
1469
- this.write("info", message, context);
1470
+ return;
1471
+ }
1472
+ function mapDatabaseError(error) {
1473
+ if (error instanceof HttpError) {
1474
+ return error;
1470
1475
  }
1471
- warn(message, context) {
1472
- 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);
1473
1479
  }
1474
- error(message, context) {
1475
- 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
+ });
1476
1503
  }
1477
1504
  }
1478
- var appLogger = new Logger("app");
1479
-
1480
- // ../../src/bootstrap/applicationRegistry.ts
1481
- var activeContext;
1482
- function setActiveApplicationContext(context) {
1483
- activeContext = context;
1484
- }
1485
- function requireActiveApplicationContext() {
1486
- if (!activeContext) {
1487
- 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);
1488
1510
  }
1489
- return activeContext;
1490
1511
  }
1491
- function resolveApplicationCache() {
1492
- return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
1493
- }
1494
- function resolveApplicationQueue() {
1495
- return requireActiveApplicationContext().container.resolve(CORE_QUEUE_TOKEN);
1496
- }
1497
- function resolveApplicationAuth() {
1498
- 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}"`;
1499
1519
  }
1500
- function resolveApplicationPolicyGate() {
1501
- return requireActiveApplicationContext().container.resolve(CORE_POLICY_GATE_TOKEN);
1520
+ function qualifyColumn(tableName, column) {
1521
+ return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
1502
1522
  }
1503
- function resolveApplicationConfig() {
1504
- 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);
1505
1532
  }
1506
- function resolveApplicationLogger() {
1507
- 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 };
1508
1539
  }
1509
- function resolveApplicationDependencies() {
1510
- return requireActiveApplicationContext().dependencies;
1540
+ function normalizeDirection(direction = "ASC") {
1541
+ return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
1511
1542
  }
1512
-
1513
- // ../../src/core/jobs/dispatchWebhookJob.ts
1514
- import { createHmac as createHmac2 } from "crypto";
1515
-
1516
- // ../../src/core/database/boundConnection.ts
1517
- var boundConnectionHolder = {
1518
- connection: null
1519
- };
1520
- function getBoundDatabaseConnection() {
1521
- return boundConnectionHolder.connection;
1543
+ function isQueryOperator(value) {
1544
+ return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
1522
1545
  }
1523
-
1524
- // ../../src/core/database/repositoryConnection.ts
1525
- function resolveRepositoryConnection() {
1526
- return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
1546
+ function pushParam(values, value) {
1547
+ values.push(value);
1548
+ return `$${values.length}`;
1527
1549
  }
1528
- var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
1529
- apply(_target, _thisArg, args) {
1530
- return resolveRepositoryConnection()(...args);
1531
- },
1532
- get(_target, property) {
1533
- const connection = resolveRepositoryConnection();
1534
- const value = connection[property];
1535
- return typeof value === "function" ? value.bind(connection) : value;
1536
- }
1537
- });
1538
-
1539
- // ../../src/core/security/safeUrl.ts
1540
- import { lookup as dnsLookupImpl } from "dns/promises";
1541
- var dnsLookup = dnsLookupImpl;
1542
- var BLOCKED_HOSTNAMES = new Set([
1543
- "localhost",
1544
- "127.0.0.1",
1545
- "0.0.0.0",
1546
- "::1",
1547
- "metadata.google.internal"
1548
- ]);
1549
- function isPrivateIpv4(hostname) {
1550
- const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
1551
- if (!match) {
1552
- return false;
1553
- }
1554
- const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
1555
- if (octets.some((octet) => octet < 0 || octet > 255)) {
1556
- return true;
1550
+ function buildInClause(column, values, params) {
1551
+ if (values.length === 0) {
1552
+ return "1 = 0";
1557
1553
  }
1558
- const [a = 0, b = 0] = octets;
1559
- if (a === 10) {
1560
- 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`);
1561
1561
  }
1562
- if (a === 127) {
1563
- return true;
1562
+ if (operator.isNull === false) {
1563
+ clauses.push(`${column} IS NOT NULL`);
1564
1564
  }
1565
- if (a === 0) {
1566
- 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
+ }
1567
1571
  }
1568
- if (a === 169 && b === 254) {
1569
- return true;
1572
+ if (operator.in !== undefined) {
1573
+ clauses.push(buildInClause(column, operator.in, params));
1570
1574
  }
1571
- if (a === 172 && b >= 16 && b <= 31) {
1572
- return true;
1575
+ if (operator.gt !== undefined) {
1576
+ clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
1573
1577
  }
1574
- if (a === 192 && b === 168) {
1575
- return true;
1578
+ if (operator.gte !== undefined) {
1579
+ clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
1576
1580
  }
1577
- return false;
1578
- }
1579
- function isBlockedHostname(hostname) {
1580
- const normalized = hostname.trim().toLowerCase();
1581
- if (normalized.length === 0) {
1582
- return true;
1581
+ if (operator.lt !== undefined) {
1582
+ clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
1583
1583
  }
1584
- if (BLOCKED_HOSTNAMES.has(normalized)) {
1585
- return true;
1584
+ if (operator.lte !== undefined) {
1585
+ clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
1586
1586
  }
1587
- if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
1588
- return true;
1587
+ if (operator.ilike !== undefined) {
1588
+ clauses.push(`${column} ILIKE ${pushParam(params, `%${operator.ilike}%`)}`);
1589
1589
  }
1590
- if (normalized.includes(":")) {
1591
- return true;
1590
+ if (operator.tsMatch !== undefined) {
1591
+ clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
1592
1592
  }
1593
- return isPrivateIpv4(normalized);
1593
+ return clauses;
1594
1594
  }
1595
- function assertSafeOutboundUrl(rawUrl, options = {}) {
1596
- let parsed;
1597
- try {
1598
- parsed = new URL(rawUrl);
1599
- } catch {
1600
- throw new BadRequestError("Webhook URL is invalid.");
1601
- }
1602
- if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
1603
- throw new BadRequestError("Webhook URL must use HTTPS.");
1604
- }
1605
- if (parsed.username || parsed.password) {
1606
- throw new BadRequestError("Webhook URL must not include credentials.");
1607
- }
1608
- if (isBlockedHostname(parsed.hostname)) {
1609
- 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)}`);
1610
1615
  }
1611
- return parsed;
1612
- }
1613
- function isBlockedIpAddress(address) {
1614
- return isBlockedHostname(address.trim().toLowerCase());
1616
+ return clauses.join(" AND ");
1615
1617
  }
1616
- async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
1617
- const parsed = assertSafeOutboundUrl(rawUrl, options);
1618
- if (options.resolveDns === false) {
1619
- return parsed;
1618
+ function buildWhereNodeClause(tableName, node, params) {
1619
+ if ("where" in node) {
1620
+ return appendWhereParts(tableName, node.where, params);
1620
1621
  }
1621
- const hostname = parsed.hostname.trim().toLowerCase();
1622
- const results = await dnsLookup(hostname, { all: true, verbatim: true });
1623
- if (results.some((result) => isBlockedIpAddress(result.address))) {
1624
- throw new BadRequestError("Webhook URL targets a blocked host.");
1622
+ const grouped = buildWhereGroupClause(tableName, node.group, params);
1623
+ if (!grouped) {
1624
+ return "";
1625
1625
  }
1626
- return parsed;
1626
+ return grouped.includes(" OR ") ? `(${grouped})` : grouped;
1627
1627
  }
1628
-
1629
- // ../../src/core/security/safeFetch.ts
1630
- var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
1631
- async function safeFetch(input, init = {}, options = {}) {
1632
- const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
1633
- const maxRedirects = options.maxRedirects ?? 0;
1634
- const resolveDns = options.resolveDns ?? appConfig.env === "production";
1635
- const urlOptions = { allowHttp: options.allowHttp, resolveDns };
1636
- const controller = new AbortController;
1637
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
1638
- try {
1639
- let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
1640
- let redirectCount = 0;
1641
- while (true) {
1642
- const response = await fetch(currentUrl, {
1643
- ...init,
1644
- signal: controller.signal,
1645
- redirect: "manual"
1646
- });
1647
- if (response.status >= 300 && response.status < 400) {
1648
- const location = response.headers.get("location");
1649
- if (!location || redirectCount >= maxRedirects) {
1650
- return response;
1651
- }
1652
- currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
1653
- redirectCount += 1;
1654
- continue;
1655
- }
1656
- 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;
1657
1634
  }
1658
- } finally {
1659
- 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 "";
1660
1643
  }
1644
+ return result;
1661
1645
  }
1662
-
1663
- // ../../src/core/jobs/dispatchWebhookJob.ts
1664
- class DispatchWebhookJob extends Job {
1665
- maxAttempts = 3;
1666
- backoffMs = 2000;
1667
- async handle(payload) {
1668
- const rows = await repositoryConnection`
1669
- SELECT id, url, secret
1670
- FROM webhook
1671
- WHERE id = ${payload.webhookId} AND active = TRUE
1672
- LIMIT 1
1673
- `;
1674
- const webhook = rows[0];
1675
- if (!webhook) {
1676
- return;
1677
- }
1678
- const body = JSON.stringify({ event: payload.event, payload: payload.payload });
1679
- const signature = createHmac2("sha256", webhook.secret).update(body).digest("hex");
1680
- assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
1681
- let responseStatus = null;
1682
- let errorMessage = null;
1683
- try {
1684
- const response = await safeFetch(webhook.url, {
1685
- method: "POST",
1686
- headers: {
1687
- "content-type": "application/json",
1688
- "x-workhub-signature": signature
1689
- },
1690
- body
1691
- }, { allowHttp: appConfig.env !== "production" });
1692
- responseStatus = response.status;
1693
- if (!response.ok) {
1694
- throw new Error(`Webhook delivery failed with status ${response.status}.`);
1695
- }
1696
- } catch (error) {
1697
- errorMessage = error instanceof Error ? error.message : String(error);
1698
- await repositoryConnection`
1699
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
1700
- VALUES (
1701
- ${webhook.id},
1702
- ${payload.event},
1703
- ${JSON.stringify(payload.payload)}::jsonb,
1704
- ${responseStatus},
1705
- ${errorMessage}
1706
- )
1707
- `;
1708
- throw error instanceof Error ? error : new Error(errorMessage);
1709
- }
1710
- await repositoryConnection`
1711
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
1712
- VALUES (
1713
- ${webhook.id},
1714
- ${payload.event},
1715
- ${JSON.stringify(payload.payload)}::jsonb,
1716
- ${responseStatus}
1717
- )
1718
- `;
1646
+ function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
1647
+ const nodes = [];
1648
+ if (Object.keys(where).length > 0) {
1649
+ nodes.push({ kind: "and", where });
1719
1650
  }
1651
+ nodes.push(...whereNodes);
1652
+ const combined = buildWhereGroupClause(tableName, nodes, params);
1653
+ return {
1654
+ clause: combined ? ` WHERE ${combined}` : "",
1655
+ params
1656
+ };
1720
1657
  }
1721
- var dispatchWebhookJob_default = DispatchWebhookJob;
1722
-
1723
- // ../../src/core/queue/jobRegistry.ts
1724
- class JobRegistry {
1725
- constructor() {}
1726
- factories = new Map;
1727
- instances = new WeakMap;
1728
- register(name, factory) {
1729
- this.factories.set(name, factory);
1658
+ function resolveSoftDeleteColumn(table) {
1659
+ if (!table.softDeletes) {
1660
+ return null;
1730
1661
  }
1731
- resolveName(job) {
1732
- return this.instances.get(job);
1662
+ if (table.softDeletes === true) {
1663
+ return "deleted_at";
1733
1664
  }
1734
- track(name, job) {
1735
- this.instances.set(job, name);
1736
- 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;
1737
1671
  }
1738
- create(name) {
1739
- const factory = this.factories.get(name);
1740
- if (!factory) {
1741
- return;
1742
- }
1743
- return factory();
1672
+ const qualifiedColumn = qualifyColumn(table.name, column);
1673
+ if (options.onlyTrashed) {
1674
+ clauses.push(`${qualifiedColumn} IS NOT NULL`);
1675
+ return;
1744
1676
  }
1745
- names() {
1746
- return [...this.factories.keys()];
1677
+ if (!options.withTrashed) {
1678
+ clauses.push(`${qualifiedColumn} IS NULL`);
1747
1679
  }
1748
1680
  }
1749
- var jobRegistry = new JobRegistry;
1750
-
1751
- // ../../src/core/pagination/index.ts
1752
- function buildPaginationMeta(input) {
1753
- 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 ");
1754
1690
  return {
1755
- page: input.page,
1756
- per_page: input.perPage,
1757
- total: input.total,
1758
- last_page: lastPage
1691
+ clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
1692
+ params: whereParams
1759
1693
  };
1760
1694
  }
1761
-
1762
- // ../../src/core/database/errors.ts
1763
- function isPostgresError(error) {
1764
- return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
1695
+ function isQueryOrder(value) {
1696
+ return "column" in value;
1765
1697
  }
1766
- function getPostgresSqlState(error) {
1767
- if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
1768
- return error.errno;
1698
+ function normalizeOrderBy(orderBy) {
1699
+ if (!orderBy) {
1700
+ return [];
1769
1701
  }
1770
- if (typeof error.errno === "number") {
1771
- return String(error.errno).padStart(5, "0");
1702
+ if (Array.isArray(orderBy)) {
1703
+ return orderBy;
1772
1704
  }
1773
- if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
1774
- return error.code;
1705
+ if (isQueryOrder(orderBy)) {
1706
+ return [orderBy];
1775
1707
  }
1776
- return;
1708
+ return Object.entries(orderBy).map(([column, direction]) => ({
1709
+ column,
1710
+ direction
1711
+ }));
1777
1712
  }
1778
- function mapDatabaseError(error) {
1779
- if (error instanceof HttpError) {
1780
- return error;
1781
- }
1782
- if (!isPostgresError(error)) {
1783
- const message = error instanceof Error ? error.message : "Database operation failed.";
1784
- return new BadRequestError(message);
1785
- }
1786
- const sqlState = getPostgresSqlState(error);
1787
- switch (sqlState) {
1788
- case "23505":
1789
- return new ConflictError(error.detail ?? "A record with these values already exists.", {
1790
- constraint: error.constraint
1791
- });
1792
- case "23503":
1793
- return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
1794
- constraint: error.constraint
1795
- });
1796
- case "23502":
1797
- return new BadRequestError(error.detail ?? "Required field is missing.", {
1798
- constraint: error.constraint
1799
- });
1800
- case "23514":
1801
- return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
1802
- constraint: error.constraint
1803
- });
1804
- default:
1805
- return new BadRequestError(error.message ?? "Database operation failed.", {
1806
- code: error.code,
1807
- sqlState
1808
- });
1809
- }
1810
- }
1811
- async function withDatabaseErrorHandling(operation) {
1812
- try {
1813
- return await operation();
1814
- } catch (error) {
1815
- throw mapDatabaseError(error);
1816
- }
1817
- }
1818
-
1819
- // ../../src/core/database/query.ts
1820
- function quoteIdentifier(identifier) {
1821
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
1822
- throw new Error(`Invalid SQL identifier: ${identifier}`);
1823
- }
1824
- return `"${identifier}"`;
1825
- }
1826
- function qualifyColumn(tableName, column) {
1827
- return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
1828
- }
1829
- function resolveQualifiedColumn(defaultTable, columnName) {
1830
- if (columnName.includes(".")) {
1831
- const [table, column] = columnName.split(".", 2);
1832
- if (!table || !column) {
1833
- throw new Error(`Invalid qualified column: ${columnName}`);
1834
- }
1835
- return qualifyColumn(table, column);
1836
- }
1837
- return qualifyColumn(defaultTable, columnName);
1838
- }
1839
- function parseQualifiedColumn(reference) {
1840
- const [table, column] = reference.split(".", 2);
1841
- if (!table || !column) {
1842
- throw new Error(`Join columns must be qualified as table.column: ${reference}`);
1843
- }
1844
- return { table, column };
1845
- }
1846
- function normalizeDirection(direction = "ASC") {
1847
- return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
1848
- }
1849
- function isQueryOperator(value) {
1850
- return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
1851
- }
1852
- function pushParam(values, value) {
1853
- values.push(value);
1854
- return `$${values.length}`;
1855
- }
1856
- function buildInClause(column, values, params) {
1857
- if (values.length === 0) {
1858
- return "1 = 0";
1859
- }
1860
- const placeholders = values.map((value) => pushParam(params, value)).join(", ");
1861
- return `${column} IN (${placeholders})`;
1862
- }
1863
- function buildOperatorClauses(column, operator, params) {
1864
- const clauses = [];
1865
- if (operator.isNull === true) {
1866
- clauses.push(`${column} IS NULL`);
1867
- }
1868
- if (operator.isNull === false) {
1869
- clauses.push(`${column} IS NOT NULL`);
1870
- }
1871
- if (operator.eq !== undefined) {
1872
- if (operator.eq === null) {
1873
- clauses.push(`${column} IS NULL`);
1874
- } else {
1875
- clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
1876
- }
1877
- }
1878
- if (operator.in !== undefined) {
1879
- clauses.push(buildInClause(column, operator.in, params));
1880
- }
1881
- if (operator.gt !== undefined) {
1882
- clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
1883
- }
1884
- if (operator.gte !== undefined) {
1885
- clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
1886
- }
1887
- if (operator.lt !== undefined) {
1888
- clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
1889
- }
1890
- if (operator.lte !== undefined) {
1891
- clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
1892
- }
1893
- if (operator.ilike !== undefined) {
1894
- clauses.push(`${column} ILIKE ${pushParam(params, `%${operator.ilike}%`)}`);
1895
- }
1896
- if (operator.tsMatch !== undefined) {
1897
- clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
1898
- }
1899
- return clauses;
1900
- }
1901
- function appendWhereParts(tableName, where, params) {
1902
- const clauses = [];
1903
- for (const [columnName, filterValue] of Object.entries(where)) {
1904
- if (filterValue === undefined) {
1905
- continue;
1906
- }
1907
- const column = resolveQualifiedColumn(tableName, columnName);
1908
- if (Array.isArray(filterValue)) {
1909
- clauses.push(buildInClause(column, filterValue, params));
1910
- continue;
1911
- }
1912
- if (isQueryOperator(filterValue)) {
1913
- clauses.push(...buildOperatorClauses(column, filterValue, params));
1914
- continue;
1915
- }
1916
- if (filterValue === null) {
1917
- clauses.push(`${column} IS NULL`);
1918
- continue;
1919
- }
1920
- clauses.push(`${column} = ${pushParam(params, filterValue)}`);
1921
- }
1922
- return clauses.join(" AND ");
1923
- }
1924
- function buildWhereNodeClause(tableName, node, params) {
1925
- if ("where" in node) {
1926
- return appendWhereParts(tableName, node.where, params);
1927
- }
1928
- const grouped = buildWhereGroupClause(tableName, node.group, params);
1929
- if (!grouped) {
1930
- return "";
1931
- }
1932
- return grouped.includes(" OR ") ? `(${grouped})` : grouped;
1933
- }
1934
- function buildWhereGroupClause(tableName, nodes, params) {
1935
- let result = "";
1936
- for (const node of nodes) {
1937
- const part = buildWhereNodeClause(tableName, node, params);
1938
- if (!part) {
1939
- continue;
1940
- }
1941
- if (!result) {
1942
- result = part;
1943
- continue;
1944
- }
1945
- result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
1946
- }
1947
- if (!result) {
1948
- return "";
1949
- }
1950
- return result;
1951
- }
1952
- function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
1953
- const nodes = [];
1954
- if (Object.keys(where).length > 0) {
1955
- nodes.push({ kind: "and", where });
1956
- }
1957
- nodes.push(...whereNodes);
1958
- const combined = buildWhereGroupClause(tableName, nodes, params);
1959
- return {
1960
- clause: combined ? ` WHERE ${combined}` : "",
1961
- params
1962
- };
1963
- }
1964
- function resolveSoftDeleteColumn(table) {
1965
- if (!table.softDeletes) {
1966
- return null;
1967
- }
1968
- if (table.softDeletes === true) {
1969
- return "deleted_at";
1970
- }
1971
- return table.softDeletes.column ?? "deleted_at";
1972
- }
1973
- function appendSoftDeleteScope(table, options, clauses) {
1974
- const column = resolveSoftDeleteColumn(table);
1975
- if (!column) {
1976
- return;
1977
- }
1978
- const qualifiedColumn = qualifyColumn(table.name, column);
1979
- if (options.onlyTrashed) {
1980
- clauses.push(`${qualifiedColumn} IS NOT NULL`);
1981
- return;
1982
- }
1983
- if (!options.withTrashed) {
1984
- clauses.push(`${qualifiedColumn} IS NULL`);
1985
- }
1986
- }
1987
- function buildQueryWhereClause(table, options = {}, whereNodes = [], params = []) {
1988
- const { clause, params: whereParams } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes, params);
1989
- const softDeleteClauses = [];
1990
- appendSoftDeleteScope(table, options, softDeleteClauses);
1991
- if (softDeleteClauses.length === 0) {
1992
- return { clause, params: whereParams };
1993
- }
1994
- const base = clause.replace(/^ WHERE /, "");
1995
- const scope = softDeleteClauses.join(" AND ");
1996
- return {
1997
- clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
1998
- params: whereParams
1999
- };
2000
- }
2001
- function isQueryOrder(value) {
2002
- return "column" in value;
2003
- }
2004
- function normalizeOrderBy(orderBy) {
2005
- if (!orderBy) {
2006
- return [];
2007
- }
2008
- if (Array.isArray(orderBy)) {
2009
- return orderBy;
2010
- }
2011
- if (isQueryOrder(orderBy)) {
2012
- return [orderBy];
2013
- }
2014
- return Object.entries(orderBy).map(([column, direction]) => ({
2015
- column,
2016
- direction
2017
- }));
2018
- }
2019
- function buildOrderByClause(tableName, orderBy) {
2020
- const parts = normalizeOrderBy(orderBy).map(({ column, direction }) => {
2021
- return `${resolveQualifiedColumn(tableName, column)} ${normalizeDirection(direction)}`;
2022
- });
2023
- return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
2024
- }
2025
- function buildGroupByClause(tableName, groupBy) {
2026
- if (!groupBy) {
2027
- 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 "";
2028
1722
  }
2029
1723
  const columns = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((column) => String(column));
2030
1724
  const parts = columns.map((column) => resolveQualifiedColumn(tableName, column));
@@ -2287,17 +1981,40 @@ function indexMorphToRelation(children, parentsByType, relation) {
2287
1981
  return result;
2288
1982
  }
2289
1983
 
2290
- // ../../src/core/database/whereBuilder.ts
2291
- class WhereBuilder {
2292
- nodes = [];
2293
- where(where) {
2294
- this.nodes.push({ kind: "and", where });
2295
- return this;
2296
- }
2297
- orWhere(where) {
2298
- this.nodes.push({ kind: "or", where });
2299
- return this;
2300
- }
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 });
2016
+ return this;
2017
+ }
2301
2018
  whereGroup(fn) {
2302
2019
  const nested = new WhereBuilder;
2303
2020
  fn(nested);
@@ -3429,179 +3146,465 @@ class FailedJobRepository extends baseRepository_default {
3429
3146
  super(failedJobTable);
3430
3147
  }
3431
3148
  }
3432
- 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
+ }
3433
3463
 
3434
- // ../../src/core/queue/failedJobService.ts
3435
- class FailedJobService {
3436
- repository;
3437
- constructor(repository) {
3438
- this.repository = repository;
3439
- }
3440
- async recordFailure(input) {
3441
- return await this.repository.create({
3442
- job_name: input.jobName,
3443
- payload: input.payload,
3444
- exception: input.exception,
3445
- failed_at: new Date
3446
- });
3447
- }
3448
- listRecent(limit = 50) {
3449
- return this.repository.findAll({
3450
- limit,
3451
- orderBy: { column: "failed_at", direction: "DESC" }
3452
- });
3453
- }
3454
- async retry(id) {
3455
- const failedJob = await this.repository.findByIdOrThrow(id, (jobId) => new Error(`Failed job ${jobId} not found.`));
3456
- await this.repository.deleteById(id);
3457
- return failedJob;
3458
- }
3459
- async delete(id) {
3460
- const deleted = await this.repository.deleteById(id);
3461
- if (!deleted) {
3462
- 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;
3463
3478
  }
3464
- }
3465
- async flush() {
3466
- const jobs = await this.repository.findAll();
3467
- let deleted = 0;
3468
- for (const job of jobs) {
3469
- if (await this.repository.deleteById(job.id)) {
3470
- 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}.`);
3471
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);
3472
3510
  }
3473
- 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
+ `;
3474
3520
  }
3475
3521
  }
3476
- var failedJobService_default = FailedJobService;
3522
+ var dispatchWebhookJob_default = DispatchWebhookJob;
3477
3523
 
3478
- // ../../src/core/queue/jobRunner.ts
3479
- async function runQueueJob(envelope, failedJobs) {
3480
- const job = jobRegistry.create(envelope.name);
3481
- if (!job) {
3482
- 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;
3483
3529
  }
3484
- const attempts = envelope.attempts ?? 0;
3485
- try {
3486
- await job.handle(envelope.payload);
3487
- } catch (error) {
3488
- const nextAttempt = attempts + 1;
3489
- const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
3490
- if (nextAttempt < maxAttempts) {
3491
- const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
3492
- await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
3493
- await runQueueJob({
3494
- ...envelope,
3495
- attempts: nextAttempt
3496
- }, 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);
3497
3541
  return;
3498
3542
  }
3499
- await failedJobs.recordFailure({
3500
- jobName: envelope.name,
3501
- payload: envelope.payload,
3502
- exception: error instanceof Error ? error.stack ?? error.message : String(error)
3503
- });
3504
- throw error;
3543
+ console.log(line);
3505
3544
  }
3506
- }
3507
-
3508
- // ../../src/core/queue/redisQueue.ts
3509
- var {RedisClient: RedisClient2 } = globalThis.Bun;
3510
- var QUEUE_LIST_KEY = "workhub:queue:default";
3511
- var QUEUE_HIGH_KEY = "workhub:queue:high";
3512
- var QUEUE_LOW_KEY = "workhub:queue:low";
3513
- function queueKeyForPriority(priority = "default") {
3514
- switch (priority) {
3515
- case "high":
3516
- return QUEUE_HIGH_KEY;
3517
- case "low":
3518
- return QUEUE_LOW_KEY;
3519
- default:
3520
- return QUEUE_LIST_KEY;
3545
+ debug(message, context) {
3546
+ this.write("debug", message, context);
3521
3547
  }
3522
- }
3523
- class RedisQueue {
3524
- client;
3525
- constructor(redisUrl) {
3526
- this.client = new RedisClient2(redisUrl);
3548
+ info(message, context) {
3549
+ this.write("info", message, context);
3527
3550
  }
3528
- async dispatch(job, payload) {
3529
- const name = jobRegistry.resolveName(job);
3530
- if (!name) {
3531
- throw new Error("Job is not registered with the queue worker registry.");
3532
- }
3533
- const envelope = {
3534
- name,
3535
- payload,
3536
- attempts: 0
3537
- };
3538
- const queueKey = queueKeyForPriority(job.priority);
3539
- 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);
3540
3556
  }
3541
3557
  }
3558
+ var appLogger = new Logger("app");
3542
3559
 
3543
- // ../../src/core/queue/resilientQueue.ts
3544
- class ResilientQueue {
3545
- failedJobs;
3546
- asyncDispatch;
3547
- constructor(failedJobs, asyncDispatch = false) {
3548
- this.failedJobs = failedJobs;
3549
- this.asyncDispatch = asyncDispatch;
3550
- }
3551
- async dispatch(job, payload) {
3552
- const name = jobRegistry.resolveName(job);
3553
- if (!name) {
3554
- throw new Error("Job is not registered with the queue worker registry.");
3555
- }
3556
- const envelope = {
3557
- name,
3558
- payload,
3559
- attempts: 0
3560
- };
3561
- if (this.asyncDispatch) {
3562
- setTimeout(() => {
3563
- runQueueJob(envelope, this.failedJobs).catch((error) => {
3564
- console.error("[ResilientQueue] Job failed:", error);
3565
- });
3566
- }, 0);
3567
- return;
3568
- }
3569
- 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.");
3570
3568
  }
3569
+ return activeContext;
3571
3570
  }
3572
-
3573
- // ../../src/core/queue/publicQueue.ts
3574
- function createFailedJobService() {
3575
- return new failedJobService_default(new failedJobRepository_default);
3571
+ function resolveApplicationCache() {
3572
+ return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
3576
3573
  }
3577
- function createTrackedJob(name, job) {
3578
- return jobRegistry.track(name, job);
3574
+ function resolveApplicationQueue() {
3575
+ return requireActiveApplicationContext().container.resolve(CORE_QUEUE_TOKEN);
3579
3576
  }
3580
- function createProductionQueue(driver, options = {}) {
3581
- options.registerJobs?.();
3582
- const failedJobs = options.failedJobs ?? createFailedJobService();
3583
- if (driver === "redis") {
3584
- if (!options.redisUrl) {
3585
- throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
3586
- }
3587
- return new RedisQueue(options.redisUrl);
3588
- }
3589
- 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;
3590
3591
  }
3591
3592
 
3592
- // ../../src/core/queue/createAppQueue.ts
3593
- var FAILED_JOB_SERVICE_TOKEN = "core.failedJobs";
3593
+ // ../../src/bootstrap/queue/defaultJobs.ts
3594
3594
  function registerDefaultJobs() {
3595
3595
  jobRegistry.register("cache.invalidate-tags", () => {
3596
3596
  return new invalidateCacheTagsJob_default(resolveApplicationCache());
3597
3597
  });
3598
3598
  jobRegistry.register("webhook.dispatch", () => new dispatchWebhookJob_default);
3599
3599
  }
3600
- 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) {
3601
3604
  return createProductionQueue(driver, {
3602
3605
  redisUrl,
3603
3606
  failedJobs,
3604
- registerJobs: registerDefaultJobs
3607
+ registerJobs
3605
3608
  });
3606
3609
  }
3607
3610
 
@@ -3709,7 +3712,7 @@ var queueProvider = {
3709
3712
  config.set("queue.driver", driver);
3710
3713
  const failedJobs = createFailedJobService();
3711
3714
  container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
3712
- 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));
3713
3716
  }
3714
3717
  };
3715
3718
  var queue_default = queueProvider;