pipework 0.252.0 → 0.253.0

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