pipework 0.251.0 → 0.252.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (730) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/config/load.d.ts +2 -0
  3. package/dist/core/config/load.d.ts.map +1 -1
  4. package/dist/core/config/load.js +1 -0
  5. package/dist/core/config/load.js.map +1 -1
  6. package/dist/core/config/namespace.d.ts +1 -0
  7. package/dist/core/config/namespace.d.ts.map +1 -1
  8. package/dist/core/config/schema.d.ts +2 -0
  9. package/dist/core/config/schema.d.ts.map +1 -1
  10. package/dist/core/config/schema.js +1 -0
  11. package/dist/core/config/schema.js.map +1 -1
  12. package/dist/core/surface/types.d.ts +1 -0
  13. package/dist/core/surface/types.d.ts.map +1 -1
  14. package/dist/data/db/namespace.d.ts +3 -0
  15. package/dist/data/db/namespace.d.ts.map +1 -1
  16. package/dist/data/db/namespace.js +3 -0
  17. package/dist/data/db/namespace.js.map +1 -1
  18. package/dist/data/db/pool.d.ts.map +1 -1
  19. package/dist/data/db/pool.js +6 -1
  20. package/dist/data/db/pool.js.map +1 -1
  21. package/dist/data/db/query-label.d.ts +5 -0
  22. package/dist/data/db/query-label.d.ts.map +1 -0
  23. package/dist/data/db/query-label.js +11 -0
  24. package/dist/data/db/query-label.js.map +1 -0
  25. package/dist/data/db/query-watch.d.ts +21 -0
  26. package/dist/data/db/query-watch.d.ts.map +1 -0
  27. package/dist/data/db/query-watch.js +90 -0
  28. package/dist/data/db/query-watch.js.map +1 -0
  29. package/dist/data/query/postgres-js/driver.js +1 -1
  30. package/dist/data/query/postgres-js/driver.js.map +1 -1
  31. package/dist/data/query/postgres-js/session.d.ts +5 -1
  32. package/dist/data/query/postgres-js/session.d.ts.map +1 -1
  33. package/dist/data/query/postgres-js/session.js +16 -11
  34. package/dist/data/query/postgres-js/session.js.map +1 -1
  35. package/dist/data/query/utils.d.ts +2 -0
  36. package/dist/data/query/utils.d.ts.map +1 -1
  37. package/dist/data/query/utils.js.map +1 -1
  38. package/dist/infra/credits/schema.d.ts +1 -1
  39. package/dist/test/plugin.d.ts.map +1 -1
  40. package/dist/test/plugin.js +10 -4
  41. package/dist/test/plugin.js.map +1 -1
  42. package/package.json +4 -3
  43. package/src/async/jobs/abortable-delay.ts +18 -0
  44. package/src/async/jobs/claims.ts +52 -0
  45. package/src/async/jobs/cron.ts +179 -0
  46. package/src/async/jobs/errors.ts +56 -0
  47. package/src/async/jobs/exclusion.ts +117 -0
  48. package/src/async/jobs/execute.ts +219 -0
  49. package/src/async/jobs/index.ts +10 -0
  50. package/src/async/jobs/listener.ts +135 -0
  51. package/src/async/jobs/namespace.ts +20 -0
  52. package/src/async/jobs/queue-door.ts +44 -0
  53. package/src/async/jobs/queue.ts +1012 -0
  54. package/src/async/jobs/run-script.ts +67 -0
  55. package/src/async/jobs/scheduler.ts +218 -0
  56. package/src/async/jobs/schedules-table.ts +37 -0
  57. package/src/async/jobs/serialize-error.ts +48 -0
  58. package/src/async/jobs/table.ts +191 -0
  59. package/src/async/pipeline/define.ts +53 -0
  60. package/src/async/pipeline/errors.ts +35 -0
  61. package/src/async/pipeline/execute.ts +53 -0
  62. package/src/async/pipeline/index.ts +13 -0
  63. package/src/async/pipeline/namespace.ts +12 -0
  64. package/src/async/pipeline/resume.ts +67 -0
  65. package/src/async/pipeline/run-steps.ts +98 -0
  66. package/src/async/pipeline/types.ts +57 -0
  67. package/src/async/state-machine/define.ts +64 -0
  68. package/src/async/state-machine/errors.ts +29 -0
  69. package/src/async/state-machine/index.ts +3 -0
  70. package/src/async/state-machine/namespace.ts +12 -0
  71. package/src/async/state-machine/transition.ts +90 -0
  72. package/src/async/state-machine/types.ts +37 -0
  73. package/src/async/steps/errors.ts +31 -0
  74. package/src/async/steps/graph.ts +1214 -0
  75. package/src/async/steps/hash.ts +40 -0
  76. package/src/async/steps/index.ts +7 -0
  77. package/src/async/steps/namespace.ts +7 -0
  78. package/src/async/steps/table.ts +97 -0
  79. package/src/async/steps/types.ts +318 -0
  80. package/src/audit/chain/index.ts +9 -0
  81. package/src/audit/chain/install.ts +113 -0
  82. package/src/audit/chain/retire.ts +52 -0
  83. package/src/audit/chain/verify.ts +257 -0
  84. package/src/audit/query/cursor.ts +37 -0
  85. package/src/audit/query/index.ts +9 -0
  86. package/src/audit/query/query.ts +223 -0
  87. package/src/audit/schema/audit-record-fields.ts +68 -0
  88. package/src/audit/schema/audit-record.ts +23 -0
  89. package/src/auth/auth/audit.ts +31 -0
  90. package/src/auth/auth/chain.ts +84 -0
  91. package/src/auth/auth/cookie.ts +136 -0
  92. package/src/auth/auth/email-links.ts +311 -0
  93. package/src/auth/auth/guard.ts +128 -0
  94. package/src/auth/auth/index.ts +18 -0
  95. package/src/auth/auth/multi-org.ts +337 -0
  96. package/src/auth/auth/namespace.ts +34 -0
  97. package/src/auth/auth/parse-expiry.ts +13 -0
  98. package/src/auth/auth/passwords.ts +110 -0
  99. package/src/auth/auth/schema.ts +110 -0
  100. package/src/auth/auth/sessions.ts +565 -0
  101. package/src/auth/auth/sign-in.ts +156 -0
  102. package/src/auth/auth/signing-keys.ts +69 -0
  103. package/src/auth/auth/types.ts +26 -0
  104. package/src/auth/auth/uuid.ts +10 -0
  105. package/src/auth/rbac/cache.ts +55 -0
  106. package/src/auth/rbac/check.ts +30 -0
  107. package/src/auth/rbac/create.ts +35 -0
  108. package/src/auth/rbac/index.ts +5 -0
  109. package/src/auth/rbac/middleware.ts +20 -0
  110. package/src/auth/rbac/namespace.ts +15 -0
  111. package/src/auth/rbac/resolve.ts +31 -0
  112. package/src/auth/rbac/schema.sql +31 -0
  113. package/src/auth/rbac/seed.ts +67 -0
  114. package/src/auth/rbac/tables.ts +32 -0
  115. package/src/auth/rbac/types.ts +65 -0
  116. package/src/auth/tenant/extract.ts +33 -0
  117. package/src/auth/tenant/in-tenant.ts +49 -0
  118. package/src/auth/tenant/index.ts +7 -0
  119. package/src/auth/tenant/namespace.ts +23 -0
  120. package/src/auth/tenant/propagate.ts +20 -0
  121. package/src/auth/tenant/rls.ts +120 -0
  122. package/src/auth/tenant/scope.ts +33 -0
  123. package/src/auth/tenant/scoped-db.ts +581 -0
  124. package/src/auth/tenant/types.ts +14 -0
  125. package/src/auth/tenant/validate.ts +27 -0
  126. package/src/cli/commands/add.ts +158 -0
  127. package/src/cli/commands/alerts.ts +98 -0
  128. package/src/cli/commands/check.ts +1418 -0
  129. package/src/cli/commands/codemod.ts +78 -0
  130. package/src/cli/commands/db.ts +356 -0
  131. package/src/cli/commands/dev.ts +101 -0
  132. package/src/cli/commands/generate.ts +75 -0
  133. package/src/cli/commands/init.ts +36 -0
  134. package/src/cli/commands/install.ts +28 -0
  135. package/src/cli/commands/lint.ts +62 -0
  136. package/src/cli/commands/migrate.ts +132 -0
  137. package/src/cli/commands/openapi.ts +63 -0
  138. package/src/cli/commands/remove.ts +72 -0
  139. package/src/cli/commands/run.ts +50 -0
  140. package/src/cli/commands/scope.ts +54 -0
  141. package/src/cli/commands/serve.ts +68 -0
  142. package/src/cli/commands/shard.ts +31 -0
  143. package/src/cli/commands/shared/config-pipework-version.ts +28 -0
  144. package/src/cli/commands/shared/find-config.ts +22 -0
  145. package/src/cli/commands/shared/load-config.ts +35 -0
  146. package/src/cli/commands/shared/pnpm-install.ts +95 -0
  147. package/src/cli/commands/shared/report-removed.ts +8 -0
  148. package/src/cli/commands/sync.ts +32 -0
  149. package/src/cli/commands/test.ts +307 -0
  150. package/src/cli/commands/upgrade.ts +48 -0
  151. package/src/cli/dev-bootstrap.ts +24 -0
  152. package/src/cli/errors.ts +10 -0
  153. package/src/cli/index.ts +303 -0
  154. package/src/cli/output.ts +17 -0
  155. package/src/cli/port.ts +15 -0
  156. package/src/cli/surface-kinds.ts +20 -0
  157. package/src/client/index.ts +4 -0
  158. package/src/client/poll.ts +130 -0
  159. package/src/client/report-fault.ts +119 -0
  160. package/src/core/config/app-pool.ts +19 -0
  161. package/src/core/config/aws-env.ts +29 -0
  162. package/src/core/config/base-dir.ts +24 -0
  163. package/src/core/config/discover.ts +64 -0
  164. package/src/core/config/env-resolve.ts +87 -0
  165. package/src/core/config/env-types.ts +116 -0
  166. package/src/core/config/env.ts +55 -0
  167. package/src/core/config/errors.ts +9 -0
  168. package/src/core/config/import-config.ts +40 -0
  169. package/src/core/config/index.ts +8 -0
  170. package/src/core/config/load.ts +417 -0
  171. package/src/core/config/namespace.ts +14 -0
  172. package/src/core/config/resolve.ts +195 -0
  173. package/src/core/config/schema.ts +488 -0
  174. package/src/core/config/statement-observer.ts +48 -0
  175. package/src/core/config/ts-inline-hooks.ts +75 -0
  176. package/src/core/config/ts-register.ts +4 -0
  177. package/src/core/config/ts-resolve-hooks.ts +101 -0
  178. package/src/core/errors/base.ts +49 -0
  179. package/src/core/errors/http.ts +146 -0
  180. package/src/core/errors/index.ts +2 -0
  181. package/src/core/errors/namespace.ts +31 -0
  182. package/src/core/lifecycle/drain.ts +33 -0
  183. package/src/core/lifecycle/health.ts +72 -0
  184. package/src/core/lifecycle/index.ts +4 -0
  185. package/src/core/lifecycle/namespace.ts +7 -0
  186. package/src/core/lifecycle/startup.ts +102 -0
  187. package/src/core/lifecycle/types.ts +36 -0
  188. package/src/core/logging/context.ts +60 -0
  189. package/src/core/logging/error-chain.ts +68 -0
  190. package/src/core/logging/index.ts +5 -0
  191. package/src/core/logging/logger.ts +65 -0
  192. package/src/core/logging/namespace.ts +16 -0
  193. package/src/core/logging/proxy.ts +22 -0
  194. package/src/core/logging/redact-match.ts +24 -0
  195. package/src/core/logging/types.ts +47 -0
  196. package/src/core/pipework.ts +566 -0
  197. package/src/core/runtime/broker.ts +69 -0
  198. package/src/core/surface/http.ts +133 -0
  199. package/src/core/surface/index.ts +11 -0
  200. package/src/core/surface/job-timeout.ts +40 -0
  201. package/src/core/surface/namespace.ts +13 -0
  202. package/src/core/surface/script.ts +33 -0
  203. package/src/core/surface/start.ts +142 -0
  204. package/src/core/surface/types.ts +225 -0
  205. package/src/core/surface/validate.ts +88 -0
  206. package/src/core/surface/worker.ts +528 -0
  207. package/src/core/validation/connectivity.ts +49 -0
  208. package/src/core/validation/domain.ts +99 -0
  209. package/src/core/validation/index.ts +5 -0
  210. package/src/core/validation/queue-tables.ts +312 -0
  211. package/src/core/validation/rls-role.ts +138 -0
  212. package/src/core/validation/startup.ts +51 -0
  213. package/src/core/validation/surfaces.ts +65 -0
  214. package/src/core/validation/types.ts +25 -0
  215. package/src/data/db/advisory-lock.ts +55 -0
  216. package/src/data/db/aggregates.ts +172 -0
  217. package/src/data/db/array-elements.ts +111 -0
  218. package/src/data/db/bulk-set.ts +53 -0
  219. package/src/data/db/col/index.ts +14 -0
  220. package/src/data/db/composed.ts +53 -0
  221. package/src/data/db/db.ts +276 -0
  222. package/src/data/db/excluded.ts +16 -0
  223. package/src/data/db/expressions.ts +473 -0
  224. package/src/data/db/filter.ts +90 -0
  225. package/src/data/db/function-source.ts +114 -0
  226. package/src/data/db/identifiers.ts +16 -0
  227. package/src/data/db/idx/index.ts +9 -0
  228. package/src/data/db/in-list.ts +49 -0
  229. package/src/data/db/index.ts +9 -0
  230. package/src/data/db/insert-split.ts +137 -0
  231. package/src/data/db/instrumentation.ts +82 -0
  232. package/src/data/db/maintenance.ts +28 -0
  233. package/src/data/db/namespace.ts +107 -0
  234. package/src/data/db/owner-labels.ts +9 -0
  235. package/src/data/db/pool.ts +187 -0
  236. package/src/data/db/query-label.ts +13 -0
  237. package/src/data/db/query-watch.ts +116 -0
  238. package/src/data/db/raw-client-warning.ts +37 -0
  239. package/src/data/db/readonly.ts +23 -0
  240. package/src/data/db/reservation.ts +151 -0
  241. package/src/data/db/schema-features.ts +14 -0
  242. package/src/data/db/serializable.ts +83 -0
  243. package/src/data/db/sql.ts +1 -0
  244. package/src/data/db/timeouts.ts +76 -0
  245. package/src/data/db/types.ts +18 -0
  246. package/src/data/db/unbound.ts +33 -0
  247. package/src/data/db/values.ts +29 -0
  248. package/src/data/domain/brand.ts +15 -0
  249. package/src/data/domain/build-factory.ts +193 -0
  250. package/src/data/domain/build-schema.ts +161 -0
  251. package/src/data/domain/build-table.ts +355 -0
  252. package/src/data/domain/define.ts +239 -0
  253. package/src/data/domain/external-registry.ts +34 -0
  254. package/src/data/domain/external.ts +77 -0
  255. package/src/data/domain/field.ts +279 -0
  256. package/src/data/domain/index.ts +13 -0
  257. package/src/data/domain/naming.ts +6 -0
  258. package/src/data/domain/projections.ts +57 -0
  259. package/src/data/domain/trace-safe-projection.ts +31 -0
  260. package/src/data/domain/traits.ts +108 -0
  261. package/src/data/domain/types.ts +289 -0
  262. package/src/data/jsonb/composed.ts +18 -0
  263. package/src/data/jsonb/index.ts +1 -0
  264. package/src/data/jsonb/namespace.ts +30 -0
  265. package/src/data/jsonb/query.ts +86 -0
  266. package/src/data/migrate/append-only-generate.ts +47 -0
  267. package/src/data/migrate/applied.ts +47 -0
  268. package/src/data/migrate/apply.ts +309 -0
  269. package/src/data/migrate/check.ts +119 -0
  270. package/src/data/migrate/diff.ts +357 -0
  271. package/src/data/migrate/door-generate.ts +130 -0
  272. package/src/data/migrate/framework-rls.ts +58 -0
  273. package/src/data/migrate/generate.ts +201 -0
  274. package/src/data/migrate/history.ts +215 -0
  275. package/src/data/migrate/index.ts +9 -0
  276. package/src/data/migrate/internal-definitions.ts +175 -0
  277. package/src/data/migrate/post-process.ts +73 -0
  278. package/src/data/migrate/pragma.ts +73 -0
  279. package/src/data/migrate/provision-framework.ts +147 -0
  280. package/src/data/migrate/rls-generate.ts +61 -0
  281. package/src/data/migrate/run.ts +124 -0
  282. package/src/data/migrate/safety.ts +167 -0
  283. package/src/data/migrate/snapshot.ts +368 -0
  284. package/src/data/migrate/sql-emit.ts +392 -0
  285. package/src/data/migrate/statements.ts +312 -0
  286. package/src/data/query/alias.ts +126 -0
  287. package/src/data/query/batch.ts +8 -0
  288. package/src/data/query/cache/core/cache.ts +78 -0
  289. package/src/data/query/cache/core/index.ts +1 -0
  290. package/src/data/query/cache/core/types.ts +29 -0
  291. package/src/data/query/cache/readme.md +230 -0
  292. package/src/data/query/casing.ts +76 -0
  293. package/src/data/query/collation.ts +11 -0
  294. package/src/data/query/column-builder.ts +370 -0
  295. package/src/data/query/column.ts +150 -0
  296. package/src/data/query/entity.ts +42 -0
  297. package/src/data/query/errors.ts +33 -0
  298. package/src/data/query/index.ts +14 -0
  299. package/src/data/query/logger.ts +47 -0
  300. package/src/data/query/operations.ts +45 -0
  301. package/src/data/query/pg-core/alias.ts +12 -0
  302. package/src/data/query/pg-core/checks.ts +32 -0
  303. package/src/data/query/pg-core/columns/all.ts +71 -0
  304. package/src/data/query/pg-core/columns/bigint.ts +110 -0
  305. package/src/data/query/pg-core/columns/bigserial.ts +128 -0
  306. package/src/data/query/pg-core/columns/boolean.ts +43 -0
  307. package/src/data/query/pg-core/columns/char.ts +85 -0
  308. package/src/data/query/pg-core/columns/cidr.ts +43 -0
  309. package/src/data/query/pg-core/columns/common.ts +366 -0
  310. package/src/data/query/pg-core/columns/custom.ts +232 -0
  311. package/src/data/query/pg-core/columns/date.common.ts +15 -0
  312. package/src/data/query/pg-core/columns/date.ts +112 -0
  313. package/src/data/query/pg-core/columns/double-precision.ts +55 -0
  314. package/src/data/query/pg-core/columns/enum.ts +201 -0
  315. package/src/data/query/pg-core/columns/index.ts +35 -0
  316. package/src/data/query/pg-core/columns/inet.ts +43 -0
  317. package/src/data/query/pg-core/columns/int.common.ts +57 -0
  318. package/src/data/query/pg-core/columns/integer.ts +53 -0
  319. package/src/data/query/pg-core/columns/interval.ts +83 -0
  320. package/src/data/query/pg-core/columns/json.ts +58 -0
  321. package/src/data/query/pg-core/columns/jsonb.ts +56 -0
  322. package/src/data/query/pg-core/columns/line.ts +117 -0
  323. package/src/data/query/pg-core/columns/macaddr.ts +43 -0
  324. package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
  325. package/src/data/query/pg-core/columns/numeric.ts +227 -0
  326. package/src/data/query/pg-core/columns/point.ts +127 -0
  327. package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
  328. package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
  329. package/src/data/query/pg-core/columns/real.ts +58 -0
  330. package/src/data/query/pg-core/columns/serial.ts +55 -0
  331. package/src/data/query/pg-core/columns/smallint.ts +53 -0
  332. package/src/data/query/pg-core/columns/smallserial.ts +60 -0
  333. package/src/data/query/pg-core/columns/text.ts +67 -0
  334. package/src/data/query/pg-core/columns/time.ts +72 -0
  335. package/src/data/query/pg-core/columns/timestamp.ts +163 -0
  336. package/src/data/query/pg-core/columns/uuid.ts +51 -0
  337. package/src/data/query/pg-core/columns/varchar.ts +89 -0
  338. package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
  339. package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
  340. package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
  341. package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
  342. package/src/data/query/pg-core/db.ts +674 -0
  343. package/src/data/query/pg-core/dialect.ts +1404 -0
  344. package/src/data/query/pg-core/expressions.ts +25 -0
  345. package/src/data/query/pg-core/foreign-keys.ts +130 -0
  346. package/src/data/query/pg-core/index.ts +21 -0
  347. package/src/data/query/pg-core/indexes.ts +276 -0
  348. package/src/data/query/pg-core/policies.ts +56 -0
  349. package/src/data/query/pg-core/primary-keys.ts +62 -0
  350. package/src/data/query/pg-core/query-builders/count.ts +86 -0
  351. package/src/data/query/pg-core/query-builders/delete.ts +293 -0
  352. package/src/data/query/pg-core/query-builders/index.ts +7 -0
  353. package/src/data/query/pg-core/query-builders/insert.ts +452 -0
  354. package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
  355. package/src/data/query/pg-core/query-builders/query.ts +157 -0
  356. package/src/data/query/pg-core/query-builders/raw.ts +49 -0
  357. package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
  358. package/src/data/query/pg-core/query-builders/select.ts +1346 -0
  359. package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
  360. package/src/data/query/pg-core/query-builders/update.ts +626 -0
  361. package/src/data/query/pg-core/roles.ts +41 -0
  362. package/src/data/query/pg-core/schema.ts +72 -0
  363. package/src/data/query/pg-core/sequence.ts +41 -0
  364. package/src/data/query/pg-core/session.ts +292 -0
  365. package/src/data/query/pg-core/subquery.ts +46 -0
  366. package/src/data/query/pg-core/table.ts +252 -0
  367. package/src/data/query/pg-core/unique-constraint.ts +73 -0
  368. package/src/data/query/pg-core/utils/array.ts +95 -0
  369. package/src/data/query/pg-core/utils/index.ts +1 -0
  370. package/src/data/query/pg-core/utils.ts +104 -0
  371. package/src/data/query/pg-core/view-base.ts +14 -0
  372. package/src/data/query/pg-core/view-common.ts +1 -0
  373. package/src/data/query/pg-core/view.ts +435 -0
  374. package/src/data/query/pg-core/with-builder.ts +41 -0
  375. package/src/data/query/postgres-js/README.md +46 -0
  376. package/src/data/query/postgres-js/array-parser.ts +45 -0
  377. package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
  378. package/src/data/query/postgres-js/driver.ts +163 -0
  379. package/src/data/query/postgres-js/index.ts +2 -0
  380. package/src/data/query/postgres-js/session.ts +234 -0
  381. package/src/data/query/primary-key.ts +11 -0
  382. package/src/data/query/query-builders/query-builder.ts +19 -0
  383. package/src/data/query/query-builders/select.types.ts +175 -0
  384. package/src/data/query/query-promise.ts +35 -0
  385. package/src/data/query/relations.ts +725 -0
  386. package/src/data/query/runnable-query.ts +12 -0
  387. package/src/data/query/selection-proxy.ts +121 -0
  388. package/src/data/query/session.ts +8 -0
  389. package/src/data/query/sql/expressions/conditions.ts +741 -0
  390. package/src/data/query/sql/expressions/index.ts +2 -0
  391. package/src/data/query/sql/expressions/select.ts +45 -0
  392. package/src/data/query/sql/functions/aggregate.ts +129 -0
  393. package/src/data/query/sql/functions/index.ts +2 -0
  394. package/src/data/query/sql/functions/vector.ts +178 -0
  395. package/src/data/query/sql/index.ts +3 -0
  396. package/src/data/query/sql/sql.ts +794 -0
  397. package/src/data/query/subquery.ts +53 -0
  398. package/src/data/query/table.ts +208 -0
  399. package/src/data/query/table.utils.ts +2 -0
  400. package/src/data/query/tracing-utils.ts +3 -0
  401. package/src/data/query/tracing.ts +11 -0
  402. package/src/data/query/utils.ts +357 -0
  403. package/src/data/query/version.ts +4 -0
  404. package/src/data/query/view-common.ts +1 -0
  405. package/src/data/schema/branded.ts +13 -0
  406. package/src/data/schema/generated/coerce.ts +5 -0
  407. package/src/data/schema/generated/combinator.ts +6 -0
  408. package/src/data/schema/generated/encoding.ts +5 -0
  409. package/src/data/schema/generated/error.ts +6 -0
  410. package/src/data/schema/generated/format.ts +5 -0
  411. package/src/data/schema/generated/type-constructor.ts +10 -0
  412. package/src/data/schema/generated/type.ts +6 -0
  413. package/src/data/schema/index.ts +6 -0
  414. package/src/data/schema/json-schema.ts +33 -0
  415. package/src/data/schema/namespace.ts +55 -0
  416. package/src/data/schema/parse.ts +39 -0
  417. package/src/data/schema/types.ts +39 -0
  418. package/src/data/temporal/append-only.ts +134 -0
  419. package/src/data/temporal/close.ts +27 -0
  420. package/src/data/temporal/columns.ts +10 -0
  421. package/src/data/temporal/definition-queries.ts +70 -0
  422. package/src/data/temporal/errors.ts +10 -0
  423. package/src/data/temporal/get-current.ts +22 -0
  424. package/src/data/temporal/index.ts +9 -0
  425. package/src/data/temporal/namespace.ts +36 -0
  426. package/src/data/temporal/query.ts +25 -0
  427. package/src/data/temporal/resolve.ts +17 -0
  428. package/src/data/temporal/revise.ts +67 -0
  429. package/src/data/temporal/types.ts +22 -0
  430. package/src/data/valve/artifact.ts +33 -0
  431. package/src/data/valve/contract-diff.ts +21 -0
  432. package/src/data/valve/cross.ts +57 -0
  433. package/src/data/valve/crossing-ledger.ts +50 -0
  434. package/src/data/valve/decision.ts +44 -0
  435. package/src/data/valve/durable-crossing.ts +52 -0
  436. package/src/data/valve/execute-crossing.ts +77 -0
  437. package/src/data/valve/faucet.ts +56 -0
  438. package/src/data/valve/gate.ts +114 -0
  439. package/src/data/valve/graph.ts +77 -0
  440. package/src/data/valve/index.ts +53 -0
  441. package/src/data/valve/namespace.ts +57 -0
  442. package/src/data/valve/registry.ts +50 -0
  443. package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
  444. package/src/data/valve/schema/valve-crossing.ts +9 -0
  445. package/src/data/valve/supply.ts +154 -0
  446. package/src/data/valve/types.ts +143 -0
  447. package/src/data/vector/columns.ts +27 -0
  448. package/src/data/vector/index.ts +4 -0
  449. package/src/data/vector/namespace.ts +33 -0
  450. package/src/data/vector/query.ts +85 -0
  451. package/src/data/vector/validate.ts +16 -0
  452. package/src/data/write/audit/actor.ts +15 -0
  453. package/src/data/write/audit/audit-columns.ts +18 -0
  454. package/src/data/write/audit/audit-select.ts +111 -0
  455. package/src/data/write/audit/bound-transaction.ts +14 -0
  456. package/src/data/write/audit/cte-compile.ts +32 -0
  457. package/src/data/write/audit/decode-returning.ts +47 -0
  458. package/src/data/write/audit/final-select.ts +43 -0
  459. package/src/data/write/audit/infra-audit.ts +36 -0
  460. package/src/data/write/audit/primary-key.ts +65 -0
  461. package/src/data/write/audit/projection.ts +41 -0
  462. package/src/data/write/audit/read-touches.ts +86 -0
  463. package/src/data/write/audit/touch-match.ts +78 -0
  464. package/src/data/write/audit/verb-delete.ts +62 -0
  465. package/src/data/write/audit/verb-insert.ts +36 -0
  466. package/src/data/write/audit/verb-select.ts +47 -0
  467. package/src/data/write/audit/verb-update.ts +60 -0
  468. package/src/data/write/audit/verb-upsert.ts +104 -0
  469. package/src/data/write/conflict.ts +43 -0
  470. package/src/data/write/delete.ts +38 -0
  471. package/src/data/write/execute.ts +243 -0
  472. package/src/data/write/index.ts +11 -0
  473. package/src/data/write/insert.ts +63 -0
  474. package/src/data/write/state.ts +43 -0
  475. package/src/data/write/update.ts +54 -0
  476. package/src/data/write/upsert.ts +58 -0
  477. package/src/data/write/values.ts +19 -0
  478. package/src/index.ts +230 -0
  479. package/src/infra/access-links/code.ts +70 -0
  480. package/src/infra/access-links/handle.ts +892 -0
  481. package/src/infra/access-links/index.ts +9 -0
  482. package/src/infra/access-links/namespace.ts +30 -0
  483. package/src/infra/access-links/person-check.ts +177 -0
  484. package/src/infra/access-links/schema.ts +71 -0
  485. package/src/infra/alerts/async-sources.ts +231 -0
  486. package/src/infra/alerts/audit.ts +36 -0
  487. package/src/infra/alerts/beat.ts +123 -0
  488. package/src/infra/alerts/client-faults.ts +111 -0
  489. package/src/infra/alerts/faults.ts +156 -0
  490. package/src/infra/alerts/handle.ts +778 -0
  491. package/src/infra/alerts/http.ts +192 -0
  492. package/src/infra/alerts/index.ts +12 -0
  493. package/src/infra/alerts/infra-sources.ts +180 -0
  494. package/src/infra/alerts/namespace.ts +81 -0
  495. package/src/infra/alerts/redact.ts +21 -0
  496. package/src/infra/alerts/request-faults.ts +41 -0
  497. package/src/infra/alerts/schema.ts +124 -0
  498. package/src/infra/alerts/sinks.ts +125 -0
  499. package/src/infra/alerts/source-options.ts +26 -0
  500. package/src/infra/alerts/sources.ts +503 -0
  501. package/src/infra/alerts/subsystem-sources.ts +137 -0
  502. package/src/infra/audit/emit.ts +87 -0
  503. package/src/infra/audit/index.ts +2 -0
  504. package/src/infra/audit/namespace.ts +22 -0
  505. package/src/infra/audit/schema.sql +35 -0
  506. package/src/infra/audit/types.ts +18 -0
  507. package/src/infra/aws/credentials.ts +147 -0
  508. package/src/infra/aws/sigv4.ts +140 -0
  509. package/src/infra/cache/create.ts +58 -0
  510. package/src/infra/cache/entry.ts +81 -0
  511. package/src/infra/cache/index.ts +4 -0
  512. package/src/infra/cache/namespace.ts +10 -0
  513. package/src/infra/cache/tenant.ts +93 -0
  514. package/src/infra/cache/types.ts +24 -0
  515. package/src/infra/crawl/audit.ts +35 -0
  516. package/src/infra/crawl/errors.ts +25 -0
  517. package/src/infra/crawl/handle.ts +693 -0
  518. package/src/infra/crawl/index.ts +9 -0
  519. package/src/infra/crawl/links.ts +160 -0
  520. package/src/infra/crawl/namespace.ts +36 -0
  521. package/src/infra/crawl/robots.ts +100 -0
  522. package/src/infra/crawl/schema.ts +89 -0
  523. package/src/infra/credit-line/handle.ts +883 -0
  524. package/src/infra/credit-line/index.ts +6 -0
  525. package/src/infra/credit-line/namespace.ts +7 -0
  526. package/src/infra/credit-line/schema.ts +96 -0
  527. package/src/infra/credits/handle.ts +706 -0
  528. package/src/infra/credits/index.ts +5 -0
  529. package/src/infra/credits/namespace.ts +7 -0
  530. package/src/infra/credits/schema.ts +83 -0
  531. package/src/infra/email/audit.ts +36 -0
  532. package/src/infra/email/inbound.ts +622 -0
  533. package/src/infra/email/index.ts +11 -0
  534. package/src/infra/email/mail.ts +517 -0
  535. package/src/infra/email/mime.ts +447 -0
  536. package/src/infra/email/namespace.ts +22 -0
  537. package/src/infra/email/providers.ts +111 -0
  538. package/src/infra/email/schema.ts +96 -0
  539. package/src/infra/email/sns.ts +200 -0
  540. package/src/infra/email/unsubscribe.ts +44 -0
  541. package/src/infra/fetch/address.ts +88 -0
  542. package/src/infra/fetch/connect-retry.ts +87 -0
  543. package/src/infra/fetch/handle.ts +329 -0
  544. package/src/infra/fetch/index.ts +7 -0
  545. package/src/infra/fetch/namespace.ts +70 -0
  546. package/src/infra/idempotency/index.ts +3 -0
  547. package/src/infra/idempotency/middleware.ts +87 -0
  548. package/src/infra/idempotency/store.ts +92 -0
  549. package/src/infra/idempotency/types.ts +30 -0
  550. package/src/infra/join-requests/handle.ts +312 -0
  551. package/src/infra/join-requests/index.ts +2 -0
  552. package/src/infra/join-requests/namespace.ts +7 -0
  553. package/src/infra/join-requests/schema.ts +33 -0
  554. package/src/infra/outbox/core.ts +165 -0
  555. package/src/infra/quota/errors.ts +32 -0
  556. package/src/infra/quota/handle.ts +215 -0
  557. package/src/infra/quota/index.ts +5 -0
  558. package/src/infra/quota/namespace.ts +7 -0
  559. package/src/infra/quota/schema.ts +15 -0
  560. package/src/infra/storage/backend.ts +34 -0
  561. package/src/infra/storage/handle.ts +513 -0
  562. package/src/infra/storage/index.ts +9 -0
  563. package/src/infra/storage/local.ts +104 -0
  564. package/src/infra/storage/namespace.ts +13 -0
  565. package/src/infra/storage/s3.ts +257 -0
  566. package/src/infra/storage/schema.ts +24 -0
  567. package/src/infra/support/handle.ts +854 -0
  568. package/src/infra/support/http.ts +229 -0
  569. package/src/infra/support/index.ts +7 -0
  570. package/src/infra/support/namespace.ts +12 -0
  571. package/src/infra/support/schema.ts +96 -0
  572. package/src/infra/webhook/inbound.ts +45 -0
  573. package/src/infra/webhook/index.ts +5 -0
  574. package/src/infra/webhook/namespace.ts +24 -0
  575. package/src/infra/webhook/outbound.ts +315 -0
  576. package/src/infra/webhook/sign.ts +32 -0
  577. package/src/infra/webhook/verify.ts +82 -0
  578. package/src/invariants/assert.ts +26 -0
  579. package/src/invariants/index.ts +1 -0
  580. package/src/lint/config.ts +56 -0
  581. package/src/lint/doctrine.ts +163 -0
  582. package/src/lint/include.ts +97 -0
  583. package/src/lint/index.ts +3 -0
  584. package/src/lint/preset.ts +10 -0
  585. package/src/lint/resolver.ts +218 -0
  586. package/src/lint/rules/no-reexports.ts +46 -0
  587. package/src/request/behavior/audited.ts +85 -0
  588. package/src/request/behavior/cached.ts +62 -0
  589. package/src/request/behavior/compose.ts +28 -0
  590. package/src/request/behavior/index.ts +5 -0
  591. package/src/request/behavior/namespace.ts +16 -0
  592. package/src/request/behavior/types.ts +26 -0
  593. package/src/request/behavior/versioned.ts +54 -0
  594. package/src/request/context/create.ts +103 -0
  595. package/src/request/context/guard.ts +49 -0
  596. package/src/request/context/index.ts +4 -0
  597. package/src/request/context/run-in-transaction.ts +200 -0
  598. package/src/request/context/store.ts +28 -0
  599. package/src/request/context/types.ts +50 -0
  600. package/src/request/di/builder.ts +535 -0
  601. package/src/request/di/index.ts +8 -0
  602. package/src/request/di/resolve.ts +271 -0
  603. package/src/request/di/types.ts +262 -0
  604. package/src/request/fitting/namespace.ts +7 -0
  605. package/src/request/fixture/batch.ts +71 -0
  606. package/src/request/fixture/builder.ts +26 -0
  607. package/src/request/fixture/errors.ts +10 -0
  608. package/src/request/fixture/index.ts +8 -0
  609. package/src/request/fixture/namespace.ts +23 -0
  610. package/src/request/fixture/paginate.ts +44 -0
  611. package/src/request/fixture/to-handlers.ts +260 -0
  612. package/src/request/fixture/types.ts +79 -0
  613. package/src/request/flow/act-as.ts +78 -0
  614. package/src/request/flow/namespace.ts +17 -0
  615. package/src/request/http/error-step.ts +42 -0
  616. package/src/request/http/file.ts +141 -0
  617. package/src/request/http/index.ts +4 -0
  618. package/src/request/http/middleware.ts +141 -0
  619. package/src/request/http/namespace.ts +5 -0
  620. package/src/request/http/rate-limit-classes.ts +170 -0
  621. package/src/request/http/response.ts +82 -0
  622. package/src/request/http/server.ts +916 -0
  623. package/src/request/http/sniff.ts +261 -0
  624. package/src/request/http/types.ts +250 -0
  625. package/src/request/http/upload.ts +284 -0
  626. package/src/request/internal/text.ts +6 -0
  627. package/src/request/openapi/generate.ts +205 -0
  628. package/src/request/openapi/index.ts +2 -0
  629. package/src/request/openapi/namespace.ts +7 -0
  630. package/src/test/auth.ts +13 -0
  631. package/src/test/auto-setup.ts +45 -0
  632. package/src/test/context.ts +91 -0
  633. package/src/test/crawl-fixture.ts +157 -0
  634. package/src/test/discover-fork.ts +17 -0
  635. package/src/test/fingerprint.ts +135 -0
  636. package/src/test/globals.d.ts +7 -0
  637. package/src/test/index.ts +19 -0
  638. package/src/test/namespace.ts +87 -0
  639. package/src/test/plugin.ts +189 -0
  640. package/src/test/progress.ts +185 -0
  641. package/src/test/reap-after-run.ts +28 -0
  642. package/src/test/reap.ts +286 -0
  643. package/src/test/reset.ts +253 -0
  644. package/src/test/savepoints.ts +172 -0
  645. package/src/test/setup.ts +1422 -0
  646. package/src/test/system-channel.ts +50 -0
  647. package/src/test/vitest-plugin.ts +142 -0
  648. package/src/test/vitest-workspace.ts +507 -0
  649. package/src/test/vitest.ts +333 -0
  650. package/src/test/watch.ts +105 -0
  651. package/src/trace/capture/bounded-serializer.ts +146 -0
  652. package/src/trace/cardinality/analyze.ts +173 -0
  653. package/src/trace/cardinality/branch-context.ts +214 -0
  654. package/src/trace/cardinality/check.ts +33 -0
  655. package/src/trace/cardinality/classify-edge.ts +64 -0
  656. package/src/trace/cardinality/structural-max.ts +107 -0
  657. package/src/trace/check/coverage-check.ts +188 -0
  658. package/src/trace/check/flow-check.ts +103 -0
  659. package/src/trace/check/flow-step-site.ts +234 -0
  660. package/src/trace/check/io-pattern.ts +116 -0
  661. package/src/trace/codemod/add-cardinality.ts +152 -0
  662. package/src/trace/codemod/run.ts +80 -0
  663. package/src/trace/entry/child-trace.ts +21 -0
  664. package/src/trace/entry/close-trace.ts +87 -0
  665. package/src/trace/entry/finalize-trace.ts +28 -0
  666. package/src/trace/entry/open-trace.ts +138 -0
  667. package/src/trace/entry/parent-trace.ts +20 -0
  668. package/src/trace/entry/parse-traceparent.ts +39 -0
  669. package/src/trace/entry/process-start.ts +38 -0
  670. package/src/trace/entry/trace-context.ts +26 -0
  671. package/src/trace/entry/tracing-enabled.ts +10 -0
  672. package/src/trace/external/record.ts +195 -0
  673. package/src/trace/external/report.ts +66 -0
  674. package/src/trace/namespace.ts +19 -0
  675. package/src/trace/partition/day.ts +47 -0
  676. package/src/trace/partition/index.ts +14 -0
  677. package/src/trace/partition/partition-name.ts +23 -0
  678. package/src/trace/partition/plan.ts +82 -0
  679. package/src/trace/partition/schedule.ts +26 -0
  680. package/src/trace/partition/startup-hook.ts +39 -0
  681. package/src/trace/partition/sync.ts +154 -0
  682. package/src/trace/partition/tables.ts +53 -0
  683. package/src/trace/read/tree.ts +83 -0
  684. package/src/trace/retain/copy-on-error.ts +76 -0
  685. package/src/trace/runtime/expire-stale-traces.ts +54 -0
  686. package/src/trace/runtime/flow-step.ts +147 -0
  687. package/src/trace/runtime/flush-steps.ts +31 -0
  688. package/src/trace/runtime/pending-writes.ts +54 -0
  689. package/src/trace/runtime/step-buffer.ts +298 -0
  690. package/src/trace/runtime/step-error.ts +21 -0
  691. package/src/trace/runtime/step-meta.ts +55 -0
  692. package/src/trace/runtime/thenable.ts +12 -0
  693. package/src/trace/runtime/truncate-trace.ts +62 -0
  694. package/src/trace/schema/trace-fields.ts +52 -0
  695. package/src/trace/schema/trace-retained.ts +22 -0
  696. package/src/trace/schema/trace-step-fields.ts +44 -0
  697. package/src/trace/schema/trace-step-retained.ts +25 -0
  698. package/src/trace/schema/trace-step.ts +18 -0
  699. package/src/trace/schema/trace.ts +12 -0
  700. package/src/trace/transformer/apply.ts +79 -0
  701. package/src/trace/transformer/build-meta-literal.ts +30 -0
  702. package/src/trace/transformer/find-flow-step-sites.ts +85 -0
  703. package/src/trace/transformer/inject-flow-step-import.ts +39 -0
  704. package/src/trace/transformer/transform-error.ts +14 -0
  705. package/src/trace/transformer/transform-flow-steps.ts +123 -0
  706. package/src/trace/transformer/wrap-function-declaration.ts +62 -0
  707. package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
  708. package/src/workspace/affected.ts +110 -0
  709. package/src/workspace/changed-files.ts +163 -0
  710. package/src/workspace/check-deps.ts +213 -0
  711. package/src/workspace/checksum.ts +20 -0
  712. package/src/workspace/dependency-graph.ts +67 -0
  713. package/src/workspace/detect-pipework-copies.ts +93 -0
  714. package/src/workspace/discover.ts +206 -0
  715. package/src/workspace/generate.ts +401 -0
  716. package/src/workspace/index.ts +7 -0
  717. package/src/workspace/module-imports-pipework.ts +39 -0
  718. package/src/workspace/module-package-name.ts +12 -0
  719. package/src/workspace/scrub-git-env.ts +23 -0
  720. package/src/workspace/source-paths.ts +36 -0
  721. package/src/workspace/sync.ts +182 -0
  722. package/dist/REFERENCE.md +0 -1477
  723. package/dist/infra/access-links/rate-limit.d.ts +0 -10
  724. package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
  725. package/dist/infra/access-links/rate-limit.js +0 -28
  726. package/dist/infra/access-links/rate-limit.js.map +0 -1
  727. package/dist/infra/email/sigv4.d.ts +0 -24
  728. package/dist/infra/email/sigv4.d.ts.map +0 -1
  729. package/dist/infra/email/sigv4.js +0 -55
  730. package/dist/infra/email/sigv4.js.map +0 -1
@@ -0,0 +1,854 @@
1
+ import { sql } from '../../data/db/sql.js'
2
+ import { pipe, pipeSystem } from '../../data/db/db.js'
3
+ import type { DB } from '../../data/db/index.js'
4
+ import { fitting } from '../../request/di/index.js'
5
+ import { requireContext, getContext } from '../../request/context/store.js'
6
+ import type { Flow } from '../../request/context/types.js'
7
+ import { inTenant } from '../../auth/tenant/in-tenant.js'
8
+ import { resolveActorId } from '../../data/write/audit/actor.js'
9
+ import { getBaseLogger } from '../../core/logging/index.js'
10
+ import { recordFault, faultMessage } from '../alerts/faults.js'
11
+ import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '../../core/errors/index.js'
12
+ import { defineStateMachine } from '../../async/state-machine/define.js'
13
+ import type { Queue } from '../../async/jobs/queue.js'
14
+ import type { Handler } from '../../request/di/types.js'
15
+ import { __flowStep } from '../../trace/runtime/flow-step.js'
16
+ import { recordBeat, beatsEnabled } from '../alerts/beat.js'
17
+ import type { Mail } from '../email/mail.js'
18
+ import type { ReceivedEmail } from '../email/inbound.js'
19
+ import { createOutbox, type Sink, type SinkDelivery } from '../outbox/core.js'
20
+ import { TICKET_STATES } from './schema.js'
21
+ import { describeError } from '../../core/logging/error-chain.js'
22
+
23
+ export type TicketState = typeof TICKET_STATES[number]
24
+ export type SupportEventKind = 'ticket.created' | 'ticket.customer-replied' | 'draft.awaiting-approval'
25
+
26
+ /** What a sink receives as `delivery.payload` for a support event. No message bodies. */
27
+ export interface SupportEventPayload {
28
+ readonly kind: SupportEventKind
29
+ readonly ticketId: string
30
+ readonly tenantId: string
31
+ readonly subject: string
32
+ readonly state: TicketState
33
+ readonly category: string | null
34
+ readonly at: string
35
+ }
36
+
37
+ /** A place ticket events are sent: `send(delivery)` gets `delivery.payload` as a `SupportEventPayload`. `alerts.webhook()` and `alerts.memory()` fit where a `SupportSink` is expected. */
38
+ export type SupportSink = Sink<SupportEventPayload>
39
+ export type SupportDelivery = SinkDelivery<SupportEventPayload>
40
+
41
+ /** What `screen` decides for a received email: `accept` it, `separate` it (open a new ticket, never add to an existing one), or `drop` it. */
42
+ export type ScreenVerdict = 'accept' | 'separate' | 'drop'
43
+
44
+ export interface SupportConfig {
45
+ /** The app's mail interface; the only caller of its `send` is `approve`. Its templates must include `support.templates`. */
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the app's own template map
47
+ readonly mail: Pick<Mail<any>, 'send'>
48
+ /**
49
+ * Where ticket events go: `alerts.webhook(...)`, `alerts.memory()` or your own `SupportSink`. A delivery's
50
+ * `kind` is the event kind, its `key` the ticket id and its `payload` a `SupportEventPayload`.
51
+ */
52
+ readonly sinks: readonly SupportSink[]
53
+ /** The queue the events job runs on; its worker surface must run `support.job`. */
54
+ readonly queue: Pick<Queue, 'enqueue'>
55
+ /**
56
+ * Whether `actorId` may approve replies for `tenantId`. Called by `approve` before anything is read or sent.
57
+ * With rbac: `({ actorId, tenantId }) => rbac.can(actorId, 'support:approve', { tenantId })`.
58
+ */
59
+ readonly canApprove: (who: { actorId: string; tenantId: string }) => Promise<boolean>
60
+ /** The author of a draft may not approve it. Default true. */
61
+ readonly distinctApprover?: boolean
62
+ /** Template name `approve` sends with; default `'pipework.support.reply'`. The app's mail templates must define it (spread `support.templates`). */
63
+ readonly replyTemplate?: string
64
+ /**
65
+ * Decides what `receiveEmail` does with a message. Default: virus verdict `FAIL` drops it; DMARC `FAIL`, or SPF and
66
+ * DKIM both `FAIL`, separates it (the From address is probably forged, so it must not add text to a ticket an agent
67
+ * is reading: it always opens a new ticket); anything else, including no verdicts (the folder stand-in), is accepted.
68
+ */
69
+ readonly screen?: (email: ReceivedEmail) => ScreenVerdict
70
+ /**
71
+ * Finds the user a sender is: `address` is lowercased. Called inside the receive's tenant transaction (`tx` is bound
72
+ * to that tenant; a throw rolls the receive back) when a message has no user of its own: for a new ticket, and for a
73
+ * message joining a ticket whose `user_id` is null, which it fills. A ticket's non-null `user_id` is never changed.
74
+ * `receive` with `contact.userId` does not call it. Null: no known user.
75
+ */
76
+ readonly resolveContact?: (contact: { address: string; name: string | null; tenantId: string }, tx: DB) => Promise<{ userId: string } | null>
77
+ /**
78
+ * Who may join a ticket by thread. `'contact'` (default): only the ticket's contact; a message from anyone else in the
79
+ * thread opens its own ticket, and `receive` with a `ticketId` refuses a sender who is not the contact. `'conversation'`:
80
+ * a message threaded into the ticket's conversation, or sent to it with `ticketId`, joins it and reopens it whoever sent
81
+ * it; the message keeps its own sender, and the ticket's contact and user do not change. Risk: with `'conversation'`,
82
+ * anyone who holds a reply's Message-ID can add to the ticket, so pair it with a strict `screen` (for example, require
83
+ * a DMARC pass). A message `screen` separates still never joins.
84
+ */
85
+ readonly joinThread?: 'contact' | 'conversation'
86
+ /** Job type of `support.job`; default `'pipework.support.events'`. */
87
+ readonly jobType?: string
88
+ /** Delivery attempts before an event is given up on; default 10. */
89
+ readonly maxAttempts?: number
90
+ /** First retry delay, doubling per attempt up to one hour; default 5000. */
91
+ readonly backoffMs?: number
92
+ }
93
+
94
+ export interface Ticket {
95
+ readonly id: string
96
+ readonly tenantId: string
97
+ readonly state: TicketState
98
+ readonly category: string | null
99
+ readonly subject: string
100
+ readonly contactEmail: string
101
+ readonly contactName: string | null
102
+ readonly userId: string | null
103
+ /** The email conversation this ticket answers into, or `null` (chat, API, or a second sender in a conversation another ticket holds). */
104
+ readonly conversationId: string | null
105
+ readonly createdAt: Date
106
+ readonly updatedAt: Date
107
+ readonly lastCustomerMessageAt: Date
108
+ }
109
+
110
+ export interface TicketMessage {
111
+ readonly id: string
112
+ readonly ticketId: string
113
+ readonly kind: 'inbound' | 'note' | 'draft' | 'outbound'
114
+ readonly channel: 'email' | 'chat' | 'api'
115
+ readonly subject: string | null
116
+ readonly text: string
117
+ readonly html: string | null
118
+ readonly fromAddress: string | null
119
+ readonly inboundEmailId: string | null
120
+ /** Stored file ids and names of an inbound email's attachments. */
121
+ readonly attachments: readonly { id: string; filename: string | null; mediaType: string; size: number }[] | null
122
+ readonly draftStatus: 'draft' | 'approved' | 'discarded' | null
123
+ /** Starts at 1; `editDraft` adds one. `approve` must be given the version the approver read. */
124
+ readonly version: number
125
+ readonly approvedBy: string | null
126
+ readonly approvedAt: Date | null
127
+ /** The `pipework_email_messages` row an approved draft (and its outbound message) was sent as. */
128
+ readonly emailMessageId: string | null
129
+ readonly createdBy: string | null
130
+ readonly createdAt: Date
131
+ }
132
+
133
+ export interface TicketLink {
134
+ readonly id: string
135
+ readonly ticketId: string
136
+ readonly kind: string
137
+ readonly ref: string
138
+ readonly label: string | null
139
+ readonly createdAt: Date
140
+ }
141
+
142
+ export interface TicketDetail {
143
+ readonly ticket: Ticket
144
+ /** Oldest first. */
145
+ readonly messages: readonly TicketMessage[]
146
+ readonly links: readonly TicketLink[]
147
+ }
148
+
149
+ export interface ListOptions {
150
+ readonly state?: TicketState
151
+ readonly category?: string
152
+ readonly limit?: number
153
+ /** The `next` of a previous page. */
154
+ readonly cursor?: string
155
+ }
156
+
157
+ export interface ListPage {
158
+ /** Newest first. */
159
+ readonly tickets: readonly Ticket[]
160
+ readonly next: string | null
161
+ }
162
+
163
+ export interface DraftInput {
164
+ readonly text: string
165
+ readonly html?: string
166
+ /** Default `Re: <ticket subject>`. */
167
+ readonly subject?: string
168
+ }
169
+
170
+ export type ReceiveResult =
171
+ | { readonly status: 'received' | 'duplicate'; readonly ticketId: string; readonly created: boolean }
172
+ | { readonly status: 'dropped' | 'no-tenant' }
173
+
174
+ export interface ReceiveInput {
175
+ readonly channel: 'chat' | 'api'
176
+ readonly contact: { readonly email: string; readonly name?: string; readonly userId?: string }
177
+ readonly text: string
178
+ readonly html?: string
179
+ /** Subject of a new ticket; default the first line of `text`. */
180
+ readonly subject?: string
181
+ /** Adds to this ticket (the contact must be the ticket's) instead of opening one. */
182
+ readonly ticketId?: string
183
+ }
184
+
185
+ /** A draft could not be approved, edited or discarded: it is already approved, discarded, or changed since `expectedVersion`. */
186
+ export class DraftStateError extends ConflictError {
187
+ constructor(readonly reason: 'approved' | 'discarded' | 'changed' | 'not-awaiting-approval', message: string) {
188
+ super(message, { reason })
189
+ }
190
+ }
191
+
192
+ /** The calls on one tenant's tickets. Each also exists as `<name>For(tenantId, ...)` for callers with no tenant in the Flow. */
193
+ export interface TicketApi {
194
+ get(ticketId: string): Promise<TicketDetail>
195
+ list(options?: ListOptions): Promise<ListPage>
196
+ /** `null` clears it. */
197
+ setCategory(ticketId: string, category: string | null): Promise<Ticket>
198
+ /** Moves the ticket along the state machine; an illegal move throws `InvalidTransitionError`. `awaiting_approval` and `answered` are reached only by drafting and approving. */
199
+ transition(ticketId: string, to: TicketState): Promise<Ticket>
200
+ /** An internal note: stored, audited, never sent. */
201
+ addNote(ticketId: string, text: string): Promise<TicketMessage>
202
+ link(ticketId: string, link: { kind: string; ref: string; label?: string }): Promise<TicketLink>
203
+ /**
204
+ * Stores a draft reply and moves the ticket to `awaiting_approval` (from `open` through `triaged`).
205
+ * A ticket has one live draft: a new draft discards the previous. Nothing is sent.
206
+ */
207
+ draftReply(ticketId: string, draft: DraftInput): Promise<{ draftId: string; version: number }>
208
+ /** Changes the live draft's content and adds one to its version; the editor becomes its author for `distinctApprover`. */
209
+ editDraft(draftId: string, draft: Partial<DraftInput>): Promise<{ draftId: string; version: number }>
210
+ /** Discards the live draft; the ticket goes back to `triaged`. */
211
+ discardDraft(draftId: string): Promise<void>
212
+ /**
213
+ * Approves and sends a draft. The actor is the Flow's `auth.userId`. Refused (`ForbiddenError`) when `canApprove`
214
+ * says no or, with `distinctApprover`, when the actor wrote the draft. Refused with `DraftStateError` when the draft
215
+ * is approved, discarded or at another version than `expectedVersion`. In one transaction the draft is marked
216
+ * approved (approver and time recorded), exactly its text and html are handed to `mail.send` as a reply in the
217
+ * ticket's conversation, an outbound message is stored and the ticket becomes `answered`.
218
+ */
219
+ approve(draftId: string, options: { expectedVersion: number }): Promise<{ messageId: string; emailMessageId: string }>
220
+ }
221
+
222
+ type WithFor<T> = T & {
223
+ [K in keyof T & string as `${K}For`]: T[K] extends (...args: infer A) => infer R ? (tenantId: string, ...args: A) => R : never
224
+ }
225
+
226
+ export interface Support extends WithFor<TicketApi> {
227
+ /**
228
+ * Hands a received email to the ticket system; call it from `mail.inbound`'s `onMessage`. It uses `email.tenantId`
229
+ * (none: logged, `{ status: 'no-tenant' }`) and is idempotent on `email.id`. A message `screen` drops (default: virus
230
+ * verdict `FAIL`) opens nothing and is audited; one it separates (default: DMARC `FAIL`, or SPF and DKIM both `FAIL`)
231
+ * always opens a new ticket and never adds to an existing one, because a forged From address must not add text to a
232
+ * ticket the agent is reading. A new conversation opens a ticket; a message in a conversation that
233
+ * has a ticket joins it only when its sender is the ticket's contact (threading is not authentication), and
234
+ * otherwise opens a new ticket, tied to the conversation if no ticket holds it yet. A customer message moves the
235
+ * ticket to `open` and discards a live draft.
236
+ */
237
+ receiveEmail(email: ReceivedEmail): Promise<ReceiveResult>
238
+ /** A customer message from another channel; same rules as `receiveEmail`. */
239
+ receive(tenantId: string, input: ReceiveInput): Promise<ReceiveResult>
240
+ /**
241
+ * Tickets of every tenant (or of `tenantId`), newest first, in the shape and cursor of `list`. Needs a Flow but no tenant.
242
+ * It reads on the system channel, which a database with `rls.platformRole` refuses over these tenant-scoped tables.
243
+ */
244
+ listAll(options?: ListOptions & { tenantId?: string }): Promise<ListPage>
245
+ /** The job to add to a worker surface's `jobs`: delivers ticket events to the sinks. */
246
+ readonly job: Handler<Record<string, unknown>, unknown>
247
+ /** What one run of `job` does; callable from a test. Needs a Flow. */
248
+ tick(): Promise<{ delivered: number; retried: number; failed: number }>
249
+ /**
250
+ * Puts the event deliveries that were given up (and never delivered) back on the queue, for every tenant, optionally
251
+ * only those of `sink` and/or that failed at or after `failedSince`; the next `tick` sends them. Delivery ids stay the
252
+ * same, so a receiver that dedupes on `delivery.id` takes each once. Writes a `requeue` audit row per delivery naming `by`
253
+ * (who asked: an operator or key name, required). Needs a Flow but no tenant.
254
+ */
255
+ requeueFailed(options: { by: string; sink?: string; failedSince?: Date }): Promise<{ requeued: number }>
256
+ /** Event deliveries, oldest first, for a ticket or all. */
257
+ deliveries(options?: { ticketId?: string }): Promise<readonly { id: string; ticketId: string; kind: SupportEventKind; sink: string; attempts: number; deliveredAt: Date | null; failedAt: Date | null; lastError: string | null }[]>
258
+ }
259
+
260
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
261
+ const DEFAULT_LIST_LIMIT = 50
262
+ const MAX_LIST_LIMIT = 200
263
+ const ENTITY = 'SupportTicket'
264
+
265
+ function toDate(value: unknown): Date {
266
+ if (value instanceof Date) return value
267
+ const text = String(value)
268
+ return new Date(/(Z|[+-]\d\d(:?\d\d)?)$/.test(text) ? text : `${text.replace(' ', 'T')}Z`)
269
+ }
270
+
271
+ function toTicket(r: Record<string, unknown>): Ticket {
272
+ return {
273
+ id: r['id'] as string, tenantId: r['tenant_id'] as string, state: r['state'] as TicketState,
274
+ category: (r['category'] ?? null) as string | null, subject: r['subject'] as string,
275
+ contactEmail: r['contact_email'] as string, contactName: (r['contact_name'] ?? null) as string | null,
276
+ userId: (r['user_id'] ?? null) as string | null, conversationId: (r['conversation_id'] ?? null) as string | null,
277
+ createdAt: toDate(r['created_at']), updatedAt: toDate(r['updated_at']), lastCustomerMessageAt: toDate(r['last_customer_message_at']),
278
+ }
279
+ }
280
+
281
+ function toMessage(r: Record<string, unknown>): TicketMessage {
282
+ return {
283
+ id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as TicketMessage['kind'],
284
+ channel: r['channel'] as TicketMessage['channel'], subject: (r['subject'] ?? null) as string | null,
285
+ text: r['text'] as string, html: (r['html'] ?? null) as string | null, fromAddress: (r['from_address'] ?? null) as string | null,
286
+ inboundEmailId: (r['inbound_email_id'] ?? null) as string | null,
287
+ attachments: (r['attachments'] ?? null) as TicketMessage['attachments'],
288
+ draftStatus: (r['draft_status'] ?? null) as TicketMessage['draftStatus'], version: Number(r['version']),
289
+ approvedBy: (r['approved_by'] ?? null) as string | null,
290
+ approvedAt: r['approved_at'] === null || r['approved_at'] === undefined ? null : toDate(r['approved_at']),
291
+ emailMessageId: (r['email_message_id'] ?? null) as string | null, createdBy: (r['created_by'] ?? null) as string | null,
292
+ createdAt: toDate(r['created_at']),
293
+ }
294
+ }
295
+
296
+ function toLink(r: Record<string, unknown>): TicketLink {
297
+ return {
298
+ id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as string, ref: r['ref'] as string,
299
+ label: (r['label'] ?? null) as string | null, createdAt: toDate(r['created_at']),
300
+ }
301
+ }
302
+
303
+ function nonEmpty(field: string, value: unknown): string {
304
+ if (typeof value !== 'string' || value.trim() === '') throw new ValidationError({ [field]: ['must be a non-empty string'] }, `Support ${field} must be a non-empty string.`)
305
+ return value
306
+ }
307
+
308
+ function requireUuid(field: string, value: unknown): string {
309
+ if (typeof value !== 'string' || !UUID_RE.test(value)) throw new ValidationError({ [field]: ['must be a UUID'] }, `Support ${field} must be a UUID.`)
310
+ return value
311
+ }
312
+
313
+ /** Mail templates for `mail.create({ templates: { ...support.templates, ... } })`: the reply is rendered verbatim from the approved draft, nothing added. */
314
+ export const supportTemplates = {
315
+ 'pipework.support.reply': ({ subject, text, html }: { subject: string; text: string; html?: string | null }): { subject: string; text: string; html?: string } =>
316
+ ({ subject, text, ...(html !== undefined && html !== null ? { html } : {}) }),
317
+ } as const
318
+
319
+ /**
320
+ * Creates the support handle: tickets for a tenant's customers, opened from received email, with replies
321
+ * sent only after an approval.
322
+ *
323
+ * Needs blueprint `support: {}` (tables `support_ticket`, `support_message`, `support_link` with forced
324
+ * row-level security, and the event outbox `support_event_delivery`) and, for email, `email: {}`.
325
+ * The app's mail templates must include `support.templates`, which render an approved draft verbatim.
326
+ *
327
+ * The only code path that sends a reply is `approve`: it needs an actor that `canApprove` accepts (and, by
328
+ * default, who is not the draft's author), and hands `mail.send` exactly the approved text and html in the
329
+ * ticket's conversation. Events (`ticket.created`, `ticket.customer-replied`, `draft.awaiting-approval`)
330
+ * go to the sinks through an outbox with retries, delivered by `job` or `tick()`. Every change is a
331
+ * `SupportTicket:<verb>` audit record under the Flow's open trace step.
332
+ */
333
+ export function createSupport(config: SupportConfig): Support {
334
+ if (config.sinks.length === 0) throw new Error('[pipework] support.create needs at least one sink.')
335
+ const sinks = new Map<string, SupportSink>()
336
+ for (const s of config.sinks) {
337
+ if (sinks.has(s.name)) throw new Error(`[pipework] support.create: two sinks are named "${s.name}"; give each its own name.`)
338
+ sinks.set(s.name, s)
339
+ }
340
+ const sinkNames = JSON.stringify([...sinks.keys()])
341
+ const joinThread = config.joinThread ?? 'contact'
342
+ if ((joinThread as string) !== 'contact' && (joinThread as string) !== 'conversation') {
343
+ throw new Error(`[pipework] support.create: joinThread must be 'contact' or 'conversation', got ${JSON.stringify(joinThread)}.`)
344
+ }
345
+ const jobType = config.jobType ?? 'pipework.support.events'
346
+ const replyTemplate = config.replyTemplate ?? 'pipework.support.reply'
347
+ const distinctApprover = config.distinctApprover ?? true
348
+ const screen = config.screen ?? ((email: ReceivedEmail): ScreenVerdict => {
349
+ const v = email.verdicts
350
+ if (v === null) return 'accept'
351
+ if (v.virus === 'FAIL') return 'drop'
352
+ return v.dmarc === 'FAIL' || (v.spf === 'FAIL' && v.dkim === 'FAIL') ? 'separate' : 'accept'
353
+ })
354
+ const log = getBaseLogger()
355
+
356
+ // Edges into `awaiting_approval`, out of it, and to `answered` are taken only by draft, discard, approve and a
357
+ // customer message (metadata.internal); `transition` cannot take them.
358
+ const internal = (ctx: { metadata?: Record<string, unknown> }): boolean => ctx.metadata?.['internal'] === true
359
+ const machine = defineStateMachine({
360
+ states: TICKET_STATES,
361
+ initial: 'open',
362
+ transitions: [
363
+ { from: 'open', to: 'triaged' },
364
+ { from: 'triaged', to: 'awaiting_approval', guard: internal },
365
+ { from: 'awaiting_approval', to: ['triaged', 'answered'], guard: internal },
366
+ { from: 'open', to: 'closed' },
367
+ { from: 'triaged', to: 'closed' },
368
+ { from: 'answered', to: 'closed' },
369
+ { from: 'triaged', to: 'open', guard: internal },
370
+ { from: 'awaiting_approval', to: 'open', guard: internal },
371
+ { from: 'answered', to: 'open', guard: internal },
372
+ { from: 'closed', to: 'open', guard: internal },
373
+ ],
374
+ })
375
+
376
+ function tenantOf(explicit: string | null, flow: Flow): string {
377
+ if (explicit !== null) return requireUuid('tenantId', explicit)
378
+ if (flow.tenant === null) {
379
+ throw new Error(
380
+ '[pipework] This support method needs a tenant in the Flow.\n\n' +
381
+ ' Call it from an authenticated tenant route, or use the ...For(tenantId, ...) form\n' +
382
+ ' (getFor, draftReplyFor, approveFor, ...) from a webhook or a job with no tenant.\n',
383
+ )
384
+ }
385
+ return flow.tenant
386
+ }
387
+
388
+ async function run<T>(explicit: string | null, fn: (tx: DB, tenantId: string, flow: Flow) => Promise<T>): Promise<T> {
389
+ const flow = requireContext()
390
+ const tenantId = tenantOf(explicit, flow)
391
+ if (explicit !== null) return inTenant(flow, tenantId, tx => fn(tx, tenantId, flow))
392
+ return pipe.transaction((tx: DB) => fn(tx, tenantId, flow))
393
+ }
394
+
395
+ // An audit record under the Flow's open trace step (none: nothing). In a transaction it is a savepoint, so a
396
+ // failed write cannot abort the change it describes.
397
+ async function audit(flow: Flow, tx: DB | null, tenantId: string, verb: string, entityId: string, details: Record<string, unknown>): Promise<void> {
398
+ const trace = flow.trace
399
+ const stepId = trace?.buffer.openStepId ?? null
400
+ if (trace === null || stepId === null) return
401
+ const write = async (db: DB): Promise<void> => {
402
+ await db.execute(sql`
403
+ INSERT INTO audit_record
404
+ (trace_day, trace_id, trace_step_id, tenant_id, actor_id, entity, verb, entity_id, after, occurred_at)
405
+ VALUES (${trace.openedDay}, ${trace.id}, ${stepId}, ${tenantId}, ${resolveActorId(flow)},
406
+ ${ENTITY}, ${verb}, ${entityId}, ${JSON.stringify(details)}::jsonb, now())`)
407
+ }
408
+ try {
409
+ if (tx === null) await write(pipeSystem())
410
+ else await tx.transaction(write)
411
+ } catch (err) {
412
+ log.error({ err, traceId: trace.id }, `Failed to write the support ${verb} audit record`)
413
+ void recordFault('audit-write', faultMessage(err), { entity: ENTITY, verb })
414
+ }
415
+ }
416
+
417
+ async function emit(tx: DB, kind: SupportEventKind, ticket: Ticket): Promise<void> {
418
+ const payload: SupportEventPayload = {
419
+ kind, ticketId: ticket.id, tenantId: ticket.tenantId, subject: ticket.subject, state: ticket.state,
420
+ category: ticket.category, at: new Date().toISOString(),
421
+ }
422
+ await tx.execute(sql`
423
+ INSERT INTO support_event_delivery (ticket_id, kind, sink, payload, created_at, next_attempt_at)
424
+ SELECT ${ticket.id}::uuid, ${kind}, s.sink, ${JSON.stringify(payload)}::jsonb, clock_timestamp(), clock_timestamp()
425
+ FROM jsonb_array_elements_text(${sinkNames}::jsonb) AS s(sink)`)
426
+ await config.queue.enqueue(tx, { type: jobType, payload: {}, continueFlow: true })
427
+ }
428
+
429
+ async function loadTicket(tx: DB, tenantId: string, ticketId: string, lock: boolean): Promise<Ticket> {
430
+ requireUuid('ticketId', ticketId)
431
+ const rows = lock
432
+ ? await tx.execute(sql`SELECT * FROM support_ticket WHERE id = ${ticketId}::uuid AND tenant_id = ${tenantId}::uuid FOR UPDATE`)
433
+ : await tx.execute(sql`SELECT * FROM support_ticket WHERE id = ${ticketId}::uuid AND tenant_id = ${tenantId}::uuid`)
434
+ if (rows[0] === undefined) throw new NotFoundError('Support ticket', { ticketId })
435
+ return toTicket(rows[0])
436
+ }
437
+
438
+ async function loadDraftRow(tx: DB, tenantId: string, draftId: string): Promise<Record<string, unknown>> {
439
+ requireUuid('draftId', draftId)
440
+ const rows = await tx.execute(sql`SELECT * FROM support_message WHERE id = ${draftId}::uuid AND tenant_id = ${tenantId}::uuid AND kind = 'draft'`)
441
+ if (rows[0] === undefined) throw new NotFoundError('Support draft', { draftId })
442
+ return rows[0]
443
+ }
444
+
445
+ async function moveTo(flow: Flow, tx: DB, ticket: Ticket, to: TicketState, internalMove: boolean): Promise<Ticket> {
446
+ await machine.transition({ entityId: ticket.id, entityType: 'SupportTicket', from: ticket.state, to, metadata: { internal: internalMove } })
447
+ const rows = await tx.execute(sql`UPDATE support_ticket SET state = ${to}, updated_at = clock_timestamp() WHERE id = ${ticket.id}::uuid RETURNING *`)
448
+ await audit(flow, tx, ticket.tenantId, 'transition', ticket.id, { from: ticket.state, to })
449
+ return toTicket(rows[0]!)
450
+ }
451
+
452
+ async function discardLive(flow: Flow, tx: DB, ticket: Ticket, reason: string): Promise<boolean> {
453
+ const rows = await tx.execute(sql`
454
+ UPDATE support_message SET draft_status = 'discarded'
455
+ WHERE ticket_id = ${ticket.id}::uuid AND kind = 'draft' AND draft_status = 'draft' RETURNING id`)
456
+ for (const r of rows) await audit(flow, tx, ticket.tenantId, 'discard', ticket.id, { draftId: r['id'], reason })
457
+ return rows.length > 0
458
+ }
459
+
460
+ async function lockConversation(tx: DB, tenantId: string, key: string): Promise<void> {
461
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`pipework.support:${tenantId}:${key}`}, 0))`)
462
+ }
463
+
464
+ interface Incoming {
465
+ readonly channel: 'email' | 'chat' | 'api'
466
+ readonly contactEmail: string
467
+ readonly contactName: string | null
468
+ readonly userId: string | null
469
+ readonly subject: string
470
+ readonly text: string
471
+ readonly html: string | null
472
+ readonly fromAddress: string | null
473
+ readonly inboundEmailId: string | null
474
+ readonly conversationId: string | null
475
+ readonly attachments: unknown
476
+ readonly ticketId: string | null
477
+ /** Never add to an existing ticket. */
478
+ readonly separate?: boolean
479
+ }
480
+
481
+ async function ingest(flow: Flow, tx: DB, tenantId: string, m: Incoming): Promise<ReceiveResult> {
482
+ await lockConversation(tx, tenantId, m.conversationId ?? m.inboundEmailId ?? m.ticketId ?? m.contactEmail)
483
+ if (m.inboundEmailId !== null) {
484
+ const seen = await tx.execute(sql`SELECT ticket_id FROM support_message WHERE tenant_id = ${tenantId}::uuid AND inbound_email_id = ${m.inboundEmailId}::uuid`)
485
+ if (seen[0] !== undefined) return { status: 'duplicate', ticketId: seen[0]['ticket_id'] as string, created: false }
486
+ }
487
+
488
+ let ticket: Ticket | null = null
489
+ if (m.ticketId !== null) {
490
+ ticket = await loadTicket(tx, tenantId, m.ticketId, true)
491
+ if (joinThread === 'contact' && ticket.contactEmail !== m.contactEmail) {
492
+ throw new ForbiddenError('The sender is not the ticket\'s contact.', { ticketId: m.ticketId })
493
+ }
494
+ } else if (m.conversationId !== null && m.separate !== true) {
495
+ const contactOnly = joinThread === 'contact' ? sql`AND t.contact_email = ${m.contactEmail}` : sql``
496
+ const found = await tx.execute(sql`
497
+ SELECT t.id FROM support_ticket t
498
+ WHERE t.tenant_id = ${tenantId}::uuid ${contactOnly}
499
+ AND (t.conversation_id = ${m.conversationId}::uuid OR EXISTS (
500
+ SELECT 1 FROM support_message sm JOIN pipework_email_inbound i ON i.id = sm.inbound_email_id
501
+ WHERE sm.ticket_id = t.id AND i.conversation_id = ${m.conversationId}::uuid))
502
+ ORDER BY (t.conversation_id = ${m.conversationId}::uuid) DESC NULLS LAST, t.created_at DESC, t.id LIMIT 1`)
503
+ if (found[0] !== undefined) ticket = await loadTicket(tx, tenantId, found[0]['id'] as string, true)
504
+ }
505
+
506
+ const resolve = async (): Promise<string | null> =>
507
+ m.userId ?? (await config.resolveContact?.({ address: m.contactEmail, name: m.contactName, tenantId }, tx))?.userId ?? null
508
+
509
+ const joinedBy = ticket !== null && ticket.contactEmail !== m.contactEmail ? 'conversation' : null
510
+ let created = false
511
+ let userIdSet: string | null = null
512
+ if (ticket === null) {
513
+ const userId = await resolve()
514
+ let conversationId = m.conversationId
515
+ if (m.separate === true) conversationId = null
516
+ if (conversationId !== null) {
517
+ const held = await tx.execute(sql`SELECT 1 FROM support_ticket WHERE tenant_id = ${tenantId}::uuid AND conversation_id = ${conversationId}::uuid`)
518
+ if (held.length > 0) conversationId = null
519
+ }
520
+ const rows = await tx.execute(sql`
521
+ INSERT INTO support_ticket (tenant_id, state, subject, contact_email, contact_name, user_id, conversation_id)
522
+ VALUES (${tenantId}::uuid, 'open', ${m.subject}, ${m.contactEmail}, ${m.contactName}, ${userId}, ${conversationId}::uuid)
523
+ RETURNING *`)
524
+ ticket = toTicket(rows[0]!)
525
+ created = true
526
+ await audit(flow, tx, tenantId, 'create', ticket.id, { subject: ticket.subject, contact: ticket.contactEmail, channel: m.channel, userId: ticket.userId })
527
+ } else {
528
+ await discardLive(flow, tx, ticket, 'customer-replied')
529
+ if (ticket.state !== 'open') ticket = await moveTo(flow, tx, ticket, 'open', true)
530
+ const viaConversation = ticket.contactEmail !== m.contactEmail
531
+ const userId = ticket.userId === null && !viaConversation ? await resolve() : null
532
+ userIdSet = userId
533
+ const rows = await tx.execute(sql`UPDATE support_ticket SET last_customer_message_at = clock_timestamp(), updated_at = clock_timestamp(), user_id = COALESCE(user_id, ${userId}) WHERE id = ${ticket.id}::uuid RETURNING *`)
534
+ ticket = toTicket(rows[0]!)
535
+ }
536
+
537
+ await tx.execute(sql`
538
+ INSERT INTO support_message (tenant_id, ticket_id, kind, channel, text, html, from_address, inbound_email_id, attachments)
539
+ VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${m.text}, ${m.html}, ${m.fromAddress},
540
+ ${m.inboundEmailId}::uuid, ${m.attachments === null ? null : JSON.stringify(m.attachments)}::jsonb)`)
541
+ await audit(flow, tx, tenantId, 'receive', ticket.id, { channel: m.channel, emailId: m.inboundEmailId, created, separated: m.separate === true, ...(joinedBy === null ? {} : { joinedBy }), ...(userIdSet === null ? {} : { userIdSet }) })
542
+ await emit(tx, created ? 'ticket.created' : 'ticket.customer-replied', ticket)
543
+ return { status: 'received', ticketId: ticket.id, created }
544
+ }
545
+
546
+ async function receiveEmail(email: ReceivedEmail): Promise<ReceiveResult> {
547
+ const flow = requireContext()
548
+ if (email.tenantId === null) {
549
+ log.warn({ emailId: email.id }, 'support: received email has no tenant; dropped')
550
+ void recordFault('support-inbound-untenanted', `received email ${email.id} has no tenant; dropped`, { emailId: email.id })
551
+ return { status: 'no-tenant' }
552
+ }
553
+ const tenantId = email.tenantId
554
+ const contactEmail = email.from.address.trim().toLowerCase()
555
+ const verdict = contactEmail === '' ? 'drop' : screen(email)
556
+ if (verdict === 'drop') {
557
+ await audit(flow, null, tenantId, 'drop', email.id, {
558
+ reason: contactEmail === '' ? 'no-sender' : 'screened', virus: email.verdicts?.virus ?? null,
559
+ })
560
+ return { status: 'dropped' }
561
+ }
562
+ const attachments = email.attachments.flatMap(a => a.file === null
563
+ ? []
564
+ : [{ id: a.file.id, filename: a.filename, mediaType: a.mediaType, size: a.size }])
565
+ return inTenant(flow, tenantId, tx => ingest(flow, tx, tenantId, {
566
+ channel: 'email', contactEmail, contactName: email.from.name === '' ? null : email.from.name, userId: null,
567
+ subject: email.subject.trim() === '' ? '(no subject)' : email.subject, text: email.text ?? '', html: email.html,
568
+ fromAddress: contactEmail, inboundEmailId: email.id, conversationId: email.conversationId,
569
+ attachments: attachments.length === 0 ? null : attachments, ticketId: null, separate: verdict === 'separate',
570
+ }))
571
+ }
572
+
573
+ async function receive(tenantId: string, input: ReceiveInput): Promise<ReceiveResult> {
574
+ const flow = requireContext()
575
+ requireUuid('tenantId', tenantId)
576
+ nonEmpty('text', input.text)
577
+ const contactEmail = nonEmpty('contact.email', input.contact.email).trim().toLowerCase()
578
+ if ((input.channel as string) !== 'chat' && (input.channel as string) !== 'api') throw new ValidationError({ channel: ['must be chat or api'] }, 'support.receive: channel must be "chat" or "api".')
579
+ const firstLine = input.text.trim().split('\n')[0]!.slice(0, 200)
580
+ return inTenant(flow, tenantId, tx => ingest(flow, tx, tenantId, {
581
+ channel: input.channel, contactEmail, contactName: input.contact.name ?? null, userId: input.contact.userId ?? null,
582
+ subject: input.subject ?? firstLine, text: input.text, html: input.html ?? null, fromAddress: contactEmail,
583
+ inboundEmailId: null, conversationId: null, attachments: null, ticketId: input.ticketId ?? null,
584
+ }))
585
+ }
586
+
587
+ async function getDetail(tx: DB, tenantId: string, ticketId: string): Promise<TicketDetail> {
588
+ const ticket = await loadTicket(tx, tenantId, ticketId, false)
589
+ const messages = await tx.execute(sql`SELECT * FROM support_message WHERE ticket_id = ${ticketId}::uuid ORDER BY created_at, id`)
590
+ const links = await tx.execute(sql`SELECT * FROM support_link WHERE ticket_id = ${ticketId}::uuid ORDER BY created_at, id`)
591
+ return { ticket, messages: messages.map(toMessage), links: links.map(toLink) }
592
+ }
593
+
594
+ async function listTickets(tx: DB, tenantId: string | null, options: ListOptions = {}): Promise<ListPage> {
595
+ const limit = Math.min(options.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT)
596
+ if (!Number.isInteger(limit) || limit < 1) throw new ValidationError({ limit: ['must be a positive integer'] }, 'support.list: limit must be a positive integer.')
597
+ let cursorAt: string | null = null
598
+ let cursorId: string | null = null
599
+ if (options.cursor !== undefined) {
600
+ const [at, id] = Buffer.from(options.cursor, 'base64url').toString('utf-8').split('|')
601
+ if (at === undefined || id === undefined || !UUID_RE.test(id) || Number.isNaN(Date.parse(at))) {
602
+ throw new ValidationError({ cursor: ['is not a cursor from a previous page'] }, 'support.list: bad cursor.')
603
+ }
604
+ cursorAt = at
605
+ cursorId = id
606
+ }
607
+ const rows = await tx.execute(sql`
608
+ SELECT t.*, t.created_at::text AS created_at_raw FROM support_ticket t
609
+ WHERE (${tenantId}::uuid IS NULL OR t.tenant_id = ${tenantId}::uuid)
610
+ AND (${options.state ?? null}::text IS NULL OR t.state = ${options.state ?? null})
611
+ AND (${options.category ?? null}::text IS NULL OR t.category = ${options.category ?? null})
612
+ AND (${cursorAt}::timestamptz IS NULL OR (t.created_at, t.id) < (${cursorAt}::timestamptz, ${cursorId}::uuid))
613
+ ORDER BY t.created_at DESC, t.id DESC LIMIT ${limit + 1}`)
614
+ const page = rows.slice(0, limit)
615
+ const last = page[page.length - 1]
616
+ const next = rows.length > limit && last !== undefined
617
+ ? Buffer.from(`${String(last['created_at_raw'])}|${String(last['id'])}`).toString('base64url')
618
+ : null
619
+ return { tickets: page.map(toTicket), next }
620
+ }
621
+
622
+ async function setCategory(flow: Flow, tx: DB, tenantId: string, ticketId: string, category: string | null): Promise<Ticket> {
623
+ if (category !== null) nonEmpty('category', category)
624
+ const before = await loadTicket(tx, tenantId, ticketId, true)
625
+ const rows = await tx.execute(sql`UPDATE support_ticket SET category = ${category}, updated_at = clock_timestamp() WHERE id = ${ticketId}::uuid RETURNING *`)
626
+ await audit(flow, tx, tenantId, 'categorise', ticketId, { from: before.category, to: category })
627
+ return toTicket(rows[0]!)
628
+ }
629
+
630
+ async function transition(flow: Flow, tx: DB, tenantId: string, ticketId: string, to: TicketState): Promise<Ticket> {
631
+ if (!(TICKET_STATES as readonly string[]).includes(to)) throw new ValidationError({ to: [`must be one of ${TICKET_STATES.join(', ')}`] }, `support.transition: unknown state "${String(to)}".`)
632
+ const ticket = await loadTicket(tx, tenantId, ticketId, true)
633
+ return moveTo(flow, tx, ticket, to, false)
634
+ }
635
+
636
+ async function addNote(flow: Flow, tx: DB, tenantId: string, ticketId: string, text: string): Promise<TicketMessage> {
637
+ nonEmpty('text', text)
638
+ await loadTicket(tx, tenantId, ticketId, true)
639
+ const rows = await tx.execute(sql`
640
+ INSERT INTO support_message (tenant_id, ticket_id, kind, channel, text, created_by)
641
+ VALUES (${tenantId}::uuid, ${ticketId}::uuid, 'note', 'api', ${text}, ${resolveActorId(flow)}) RETURNING *`)
642
+ await tx.execute(sql`UPDATE support_ticket SET updated_at = clock_timestamp() WHERE id = ${ticketId}::uuid`)
643
+ await audit(flow, tx, tenantId, 'note', ticketId, { messageId: rows[0]!['id'] })
644
+ return toMessage(rows[0]!)
645
+ }
646
+
647
+ async function link(flow: Flow, tx: DB, tenantId: string, ticketId: string, l: { kind: string; ref: string; label?: string }): Promise<TicketLink> {
648
+ nonEmpty('kind', l.kind)
649
+ nonEmpty('ref', l.ref)
650
+ await loadTicket(tx, tenantId, ticketId, true)
651
+ await tx.execute(sql`
652
+ INSERT INTO support_link (tenant_id, ticket_id, kind, ref, label)
653
+ VALUES (${tenantId}::uuid, ${ticketId}::uuid, ${l.kind}, ${l.ref}, ${l.label ?? null})
654
+ ON CONFLICT (ticket_id, kind, ref) DO UPDATE SET label = COALESCE(EXCLUDED.label, support_link.label)`)
655
+ const rows = await tx.execute(sql`SELECT * FROM support_link WHERE ticket_id = ${ticketId}::uuid AND kind = ${l.kind} AND ref = ${l.ref}`)
656
+ await audit(flow, tx, tenantId, 'link', ticketId, { kind: l.kind, ref: l.ref })
657
+ return toLink(rows[0]!)
658
+ }
659
+
660
+ async function draftReply(flow: Flow, tx: DB, tenantId: string, ticketId: string, draft: DraftInput): Promise<{ draftId: string; version: number }> {
661
+ nonEmpty('text', draft.text)
662
+ if (draft.subject !== undefined) nonEmpty('subject', draft.subject)
663
+ let ticket = await loadTicket(tx, tenantId, ticketId, true)
664
+ if (ticket.state === 'awaiting_approval') {
665
+ await discardLive(flow, tx, ticket, 'replaced')
666
+ } else {
667
+ // From open the draft passes through triaged: both moves are taken and audited.
668
+ if (ticket.state === 'open') ticket = await moveTo(flow, tx, ticket, 'triaged', true)
669
+ ticket = await moveTo(flow, tx, ticket, 'awaiting_approval', true)
670
+ }
671
+ const rows = await tx.execute(sql`
672
+ INSERT INTO support_message (tenant_id, ticket_id, kind, channel, subject, text, html, draft_status, created_by)
673
+ VALUES (${tenantId}::uuid, ${ticketId}::uuid, 'draft', 'email', ${draft.subject ?? null}, ${draft.text}, ${draft.html ?? null}, 'draft', ${resolveActorId(flow)})
674
+ RETURNING id, version`)
675
+ const draftId = rows[0]!['id'] as string
676
+ await audit(flow, tx, tenantId, 'draft', ticketId, { draftId, version: 1 })
677
+ await emit(tx, 'draft.awaiting-approval', ticket)
678
+ return { draftId, version: Number(rows[0]!['version']) }
679
+ }
680
+
681
+ async function editDraft(flow: Flow, tx: DB, tenantId: string, draftId: string, patch: Partial<DraftInput>): Promise<{ draftId: string; version: number }> {
682
+ if (patch.text !== undefined) nonEmpty('text', patch.text)
683
+ if (patch.subject !== undefined) nonEmpty('subject', patch.subject)
684
+ const row = await loadDraftRow(tx, tenantId, draftId)
685
+ await loadTicket(tx, tenantId, row['ticket_id'] as string, true)
686
+ const rows = await tx.execute(sql`
687
+ UPDATE support_message SET
688
+ text = COALESCE(${patch.text ?? null}, text), html = COALESCE(${patch.html ?? null}, html),
689
+ subject = COALESCE(${patch.subject ?? null}, subject), version = version + 1, created_by = ${resolveActorId(flow)}
690
+ WHERE id = ${draftId}::uuid AND draft_status = 'draft' RETURNING version`)
691
+ if (rows[0] === undefined) throw staleDraft(String(row['draft_status']))
692
+ const version = Number(rows[0]['version'])
693
+ await audit(flow, tx, tenantId, 'draft', row['ticket_id'] as string, { draftId, version, edited: true })
694
+ return { draftId, version }
695
+ }
696
+
697
+ function staleDraft(status: string, version?: { expected: number; actual: number }): DraftStateError {
698
+ if (status === 'approved') return new DraftStateError('approved', 'This draft is already approved.')
699
+ if (status === 'discarded') return new DraftStateError('discarded', 'This draft was discarded.')
700
+ return new DraftStateError('changed', `This draft changed since it was read (version ${String(version?.actual)}, expected ${String(version?.expected)}).`)
701
+ }
702
+
703
+ async function discardDraft(flow: Flow, tx: DB, tenantId: string, draftId: string): Promise<void> {
704
+ const row = await loadDraftRow(tx, tenantId, draftId)
705
+ const ticket = await loadTicket(tx, tenantId, row['ticket_id'] as string, true)
706
+ const rows = await tx.execute(sql`UPDATE support_message SET draft_status = 'discarded' WHERE id = ${draftId}::uuid AND draft_status = 'draft' RETURNING id`)
707
+ if (rows[0] === undefined) throw staleDraft(String(row['draft_status']))
708
+ await audit(flow, tx, tenantId, 'discard', ticket.id, { draftId, reason: 'discarded' })
709
+ await moveTo(flow, tx, ticket, 'triaged', true)
710
+ }
711
+
712
+ async function approve(explicit: string | null, draftId: string, options: { expectedVersion: number }): Promise<{ messageId: string; emailMessageId: string }> {
713
+ const flow = requireContext()
714
+ const tenantId = tenantOf(explicit, flow)
715
+ const actorId = resolveActorId(flow)
716
+ if (actorId === null) throw new ForbiddenError('Approving a reply needs an authenticated actor.')
717
+ if (!Number.isInteger(options.expectedVersion)) throw new ValidationError({ expectedVersion: ['must be an integer'] }, 'support.approve needs the expectedVersion the approver read.')
718
+ if (!(await config.canApprove({ actorId, tenantId }))) {
719
+ throw new ForbiddenError('This actor may not approve replies.', { actorId, tenantId })
720
+ }
721
+ return run(explicit, async (tx) => {
722
+ // The ticket row is locked first, so a customer message, a discard and a second approve wait for this one.
723
+ const row = await loadDraftRow(tx, tenantId, draftId)
724
+ const ticket = await loadTicket(tx, tenantId, row['ticket_id'] as string, true)
725
+ const draft = toMessage(await loadDraftRow(tx, tenantId, draftId))
726
+ if (distinctApprover && draft.createdBy === actorId) {
727
+ throw new ForbiddenError('A draft cannot be approved by its author.', { actorId })
728
+ }
729
+ const claimed = await tx.execute(sql`
730
+ UPDATE support_message SET draft_status = 'approved', approved_by = ${actorId}, approved_at = clock_timestamp()
731
+ WHERE id = ${draftId}::uuid AND draft_status = 'draft' AND version = ${options.expectedVersion}
732
+ RETURNING *`)
733
+ if (claimed[0] === undefined) throw staleDraft(draft.draftStatus ?? 'changed', { expected: options.expectedVersion, actual: draft.version })
734
+ // What is sent is what the update matched, not the earlier read.
735
+ const approved = toMessage(claimed[0])
736
+ if (ticket.state !== 'awaiting_approval') {
737
+ throw new DraftStateError('not-awaiting-approval', `The ticket is ${ticket.state}, not awaiting approval.`)
738
+ }
739
+ const parent = await tx.execute(sql`
740
+ SELECT inbound_email_id FROM support_message
741
+ WHERE ticket_id = ${ticket.id}::uuid AND kind = 'inbound' AND inbound_email_id IS NOT NULL
742
+ ORDER BY created_at DESC, id DESC LIMIT 1`)
743
+ const inReplyTo = (parent[0]?.['inbound_email_id'] ?? null) as string | null
744
+ const subject = approved.subject ?? (/^re:/i.test(ticket.subject) ? ticket.subject : `Re: ${ticket.subject}`)
745
+ const sent = await config.mail.send(tx, {
746
+ to: ticket.contactEmail, template: replyTemplate,
747
+ data: { subject, text: approved.text, ...(approved.html !== null ? { html: approved.html } : {}) },
748
+ key: `support-reply:${draftId}`,
749
+ ...(ticket.conversationId !== null ? { conversationId: ticket.conversationId } : {}),
750
+ ...(inReplyTo !== null ? { inReplyTo } : {}),
751
+ })
752
+ await tx.execute(sql`UPDATE support_message SET email_message_id = ${sent.messageId}::uuid WHERE id = ${draftId}::uuid`)
753
+ const outbound = await tx.execute(sql`
754
+ INSERT INTO support_message (tenant_id, ticket_id, kind, channel, subject, text, html, approved_by, approved_at, email_message_id, created_by)
755
+ VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'outbound', 'email', ${subject}, ${approved.text}, ${approved.html}, ${actorId},
756
+ clock_timestamp(), ${sent.messageId}::uuid, ${approved.createdBy})
757
+ RETURNING id`)
758
+ await moveTo(flow, tx, ticket, 'answered', true)
759
+ await audit(flow, tx, tenantId, 'approve', ticket.id, { draftId, version: draft.version, approvedBy: actorId, emailMessageId: sent.messageId, author: draft.createdBy })
760
+ return { messageId: outbound[0]!['id'] as string, emailMessageId: sent.messageId }
761
+ })
762
+ }
763
+
764
+ const outbox = createOutbox<SupportDelivery>({
765
+ table: 'support_event_delivery', groupColumn: 'ticket_id', database: undefined, sinks,
766
+ maxAttempts: config.maxAttempts ?? 10, backoffMs: config.backoffMs ?? 5000, label: 'support event delivery given up',
767
+ toDelivery: (row, attempt) => ({
768
+ id: row['id'] as string, kind: row['kind'] as SupportEventKind, key: row['ticket_id'] as string, sink: row['sink'] as string,
769
+ payload: row['payload'] as SupportEventPayload, attempt,
770
+ }),
771
+ onDelivered: (row, attempt) => auditDelivery('deliver', row, { attempt }),
772
+ onGivenUp: (row, attempt, error) => auditDelivery('deliver-failed', row, { attempt, error }),
773
+ })
774
+
775
+ async function auditDelivery(verb: string, row: Record<string, unknown>, extra: Record<string, unknown>): Promise<void> {
776
+ const flow = requireContext()
777
+ const payload = row['payload'] as SupportEventPayload
778
+ await audit(flow, null, payload.tenantId, verb, row['ticket_id'] as string, { sink: row['sink'], kind: row['kind'], ...extra })
779
+ }
780
+
781
+ const drainStep = __flowStep({ ref: 'pipework/support:tick' }, () => outbox.drain())
782
+ // A beat for `alerts.source.loopStalled`, written only where the blueprint has `alerts`.
783
+ const beat = async (outcome: { ok: boolean; error?: string }): Promise<void> => {
784
+ const ctx = getContext()
785
+ if (ctx === undefined || !beatsEnabled(ctx.instance.config)) return
786
+ await recordBeat(() => pipeSystem(), 'support.events', { ...outcome, everyMs: 60_000 })
787
+ }
788
+ const tickStep = async () => {
789
+ try {
790
+ const result = await drainStep()
791
+ await beat({ ok: true })
792
+ return result
793
+ } catch (err) {
794
+ await beat({ ok: false, error: describeError(err).message })
795
+ throw err
796
+ }
797
+ }
798
+ const job = fitting.use().job(jobType).fit(async () => tickStep())
799
+
800
+ // Each call exists twice: for the Flow's tenant, and as `<name>For(tenantId, ...)`.
801
+ const ops: Record<Exclude<keyof TicketApi, 'approve'>, (flow: Flow, tx: DB, tenantId: string, ...args: never[]) => Promise<unknown>> = {
802
+ get: (_f, tx, t, id: string) => getDetail(tx, t, id),
803
+ list: (_f, tx, t, o?: ListOptions) => listTickets(tx, t, o),
804
+ setCategory: (f, tx, t, id: string, c: string | null) => setCategory(f, tx, t, id, c),
805
+ transition: (f, tx, t, id: string, to: TicketState) => transition(f, tx, t, id, to),
806
+ addNote: (f, tx, t, id: string, text: string) => addNote(f, tx, t, id, text),
807
+ link: (f, tx, t, id: string, l: { kind: string; ref: string; label?: string }) => link(f, tx, t, id, l),
808
+ draftReply: (f, tx, t, id: string, d: DraftInput) => draftReply(f, tx, t, id, d),
809
+ editDraft: (f, tx, t, id: string, d: Partial<DraftInput>) => editDraft(f, tx, t, id, d),
810
+ discardDraft: (f, tx, t, id: string) => discardDraft(f, tx, t, id),
811
+ }
812
+ const api: Record<string, unknown> = {
813
+ approve: (draftId: string, options: { expectedVersion: number }) => approve(null, draftId, options),
814
+ approveFor: (tenantId: string, draftId: string, options: { expectedVersion: number }) => approve(tenantId, draftId, options),
815
+ }
816
+ for (const [name, fn] of Object.entries(ops)) {
817
+ const call = fn as (flow: Flow, tx: DB, tenantId: string, ...args: unknown[]) => Promise<unknown>
818
+ api[name] = (...args: unknown[]) => run(null, (tx, t, f) => call(f, tx, t, ...args))
819
+ api[`${name}For`] = (tenantId: string, ...args: unknown[]) => run(tenantId, (tx, t, f) => call(f, tx, t, ...args))
820
+ }
821
+
822
+ return {
823
+ ...(api as unknown as WithFor<TicketApi>),
824
+ receiveEmail,
825
+ receive,
826
+ async listAll(options = {}) {
827
+ requireContext()
828
+ const { tenantId, ...rest } = options
829
+ if (tenantId !== undefined) requireUuid('tenantId', tenantId)
830
+ return listTickets(pipeSystem(), tenantId ?? null, rest)
831
+ },
832
+ job: job as unknown as Handler<Record<string, unknown>, unknown>,
833
+ tick: () => tickStep(),
834
+ async requeueFailed(options) {
835
+ requireContext()
836
+ nonEmpty('by', options.by)
837
+ const requeued = await outbox.requeue({ ...(options.sink !== undefined ? { sink: options.sink } : {}), ...(options.failedSince !== undefined ? { failedSince: options.failedSince } : {}) })
838
+ for (const row of requeued) {
839
+ await auditDelivery('requeue', row, { by: options.by.trim(), attempts: Number(row['prev_attempts']), error: row['last_error'] ?? null })
840
+ }
841
+ return { requeued: requeued.length }
842
+ },
843
+ async deliveries(options = {}) {
844
+ const rows = options.ticketId === undefined
845
+ ? await pipeSystem().execute(sql`SELECT * FROM support_event_delivery ORDER BY created_at, id`)
846
+ : await pipeSystem().execute(sql`SELECT * FROM support_event_delivery WHERE ticket_id = ${options.ticketId}::uuid ORDER BY created_at, id`)
847
+ return rows.map(r => ({
848
+ id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as SupportEventKind, sink: r['sink'] as string,
849
+ attempts: Number(r['attempts']), deliveredAt: r['delivered_at'] === null ? null : toDate(r['delivered_at']),
850
+ failedAt: r['failed_at'] === null ? null : toDate(r['failed_at']), lastError: (r['last_error'] ?? null) as string | null,
851
+ }))
852
+ },
853
+ }
854
+ }