pipework 0.212.0 → 0.243.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 (318) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/REFERENCE.md +94 -2
  3. package/dist/REFERENCE.md +472 -16
  4. package/dist/cli/commands/openapi.d.ts +8 -0
  5. package/dist/cli/commands/openapi.d.ts.map +1 -0
  6. package/dist/cli/commands/openapi.js +49 -0
  7. package/dist/cli/commands/openapi.js.map +1 -0
  8. package/dist/cli/commands/serve.d.ts +8 -0
  9. package/dist/cli/commands/serve.d.ts.map +1 -1
  10. package/dist/cli/commands/serve.js +22 -3
  11. package/dist/cli/commands/serve.js.map +1 -1
  12. package/dist/cli/dev-bootstrap.js +6 -1
  13. package/dist/cli/dev-bootstrap.js.map +1 -1
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +15 -1
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cli/surface-kinds.d.ts +5 -0
  18. package/dist/cli/surface-kinds.d.ts.map +1 -0
  19. package/dist/cli/surface-kinds.js +15 -0
  20. package/dist/cli/surface-kinds.js.map +1 -0
  21. package/dist/core/config/load.d.ts +1 -0
  22. package/dist/core/config/load.d.ts.map +1 -1
  23. package/dist/core/config/load.js +1 -0
  24. package/dist/core/config/load.js.map +1 -1
  25. package/dist/core/config/namespace.d.ts +6 -0
  26. package/dist/core/config/namespace.d.ts.map +1 -1
  27. package/dist/core/config/schema.d.ts +15 -0
  28. package/dist/core/config/schema.d.ts.map +1 -1
  29. package/dist/core/config/schema.js +11 -2
  30. package/dist/core/config/schema.js.map +1 -1
  31. package/dist/core/lifecycle/health.js +2 -2
  32. package/dist/core/lifecycle/health.js.map +1 -1
  33. package/dist/core/lifecycle/types.d.ts +2 -0
  34. package/dist/core/lifecycle/types.d.ts.map +1 -1
  35. package/dist/core/pipework.d.ts +14 -1
  36. package/dist/core/pipework.d.ts.map +1 -1
  37. package/dist/core/pipework.js +18 -2
  38. package/dist/core/pipework.js.map +1 -1
  39. package/dist/core/surface/http.d.ts +3 -0
  40. package/dist/core/surface/http.d.ts.map +1 -1
  41. package/dist/core/surface/http.js +27 -8
  42. package/dist/core/surface/http.js.map +1 -1
  43. package/dist/core/surface/start.d.ts.map +1 -1
  44. package/dist/core/surface/start.js +19 -3
  45. package/dist/core/surface/start.js.map +1 -1
  46. package/dist/core/surface/types.d.ts +8 -0
  47. package/dist/core/surface/types.d.ts.map +1 -1
  48. package/dist/data/db/filter.d.ts +3 -0
  49. package/dist/data/db/filter.d.ts.map +1 -1
  50. package/dist/data/db/filter.js +3 -0
  51. package/dist/data/db/filter.js.map +1 -1
  52. package/dist/data/db/in-list.d.ts +15 -0
  53. package/dist/data/db/in-list.d.ts.map +1 -0
  54. package/dist/data/db/in-list.js +51 -0
  55. package/dist/data/db/in-list.js.map +1 -0
  56. package/dist/data/db/namespace.d.ts +2 -0
  57. package/dist/data/db/namespace.d.ts.map +1 -1
  58. package/dist/data/db/pool.d.ts +1 -1
  59. package/dist/data/db/pool.d.ts.map +1 -1
  60. package/dist/data/db/pool.js +4 -2
  61. package/dist/data/db/pool.js.map +1 -1
  62. package/dist/data/db/reservation.d.ts +40 -0
  63. package/dist/data/db/reservation.d.ts.map +1 -0
  64. package/dist/data/db/reservation.js +108 -0
  65. package/dist/data/db/reservation.js.map +1 -0
  66. package/dist/data/domain/build-table.js +32 -1
  67. package/dist/data/domain/build-table.js.map +1 -1
  68. package/dist/data/domain/index.d.ts +2 -1
  69. package/dist/data/domain/index.d.ts.map +1 -1
  70. package/dist/data/domain/types.d.ts +9 -1
  71. package/dist/data/domain/types.d.ts.map +1 -1
  72. package/dist/data/domain/types.js.map +1 -1
  73. package/dist/data/migrate/apply.d.ts +14 -0
  74. package/dist/data/migrate/apply.d.ts.map +1 -1
  75. package/dist/data/migrate/apply.js +13 -13
  76. package/dist/data/migrate/apply.js.map +1 -1
  77. package/dist/data/migrate/check.d.ts.map +1 -1
  78. package/dist/data/migrate/check.js +3 -6
  79. package/dist/data/migrate/check.js.map +1 -1
  80. package/dist/data/migrate/diff.d.ts +16 -0
  81. package/dist/data/migrate/diff.d.ts.map +1 -1
  82. package/dist/data/migrate/diff.js +44 -4
  83. package/dist/data/migrate/diff.js.map +1 -1
  84. package/dist/data/migrate/generate.d.ts +1 -1
  85. package/dist/data/migrate/generate.d.ts.map +1 -1
  86. package/dist/data/migrate/generate.js +19 -51
  87. package/dist/data/migrate/generate.js.map +1 -1
  88. package/dist/data/migrate/history.d.ts +36 -0
  89. package/dist/data/migrate/history.d.ts.map +1 -0
  90. package/dist/data/migrate/history.js +185 -0
  91. package/dist/data/migrate/history.js.map +1 -0
  92. package/dist/data/migrate/internal-definitions.d.ts.map +1 -1
  93. package/dist/data/migrate/internal-definitions.js +22 -1
  94. package/dist/data/migrate/internal-definitions.js.map +1 -1
  95. package/dist/data/migrate/provision-framework.d.ts +2 -0
  96. package/dist/data/migrate/provision-framework.d.ts.map +1 -1
  97. package/dist/data/migrate/provision-framework.js +17 -5
  98. package/dist/data/migrate/provision-framework.js.map +1 -1
  99. package/dist/data/migrate/snapshot.d.ts +4 -0
  100. package/dist/data/migrate/snapshot.d.ts.map +1 -1
  101. package/dist/data/migrate/snapshot.js +2 -0
  102. package/dist/data/migrate/snapshot.js.map +1 -1
  103. package/dist/data/migrate/sql-emit.js +66 -12
  104. package/dist/data/migrate/sql-emit.js.map +1 -1
  105. package/dist/data/query/pg-core/foreign-keys.d.ts +5 -0
  106. package/dist/data/query/pg-core/foreign-keys.d.ts.map +1 -1
  107. package/dist/data/query/pg-core/foreign-keys.js +9 -0
  108. package/dist/data/query/pg-core/foreign-keys.js.map +1 -1
  109. package/dist/data/query/pg-core/indexes.d.ts +5 -0
  110. package/dist/data/query/pg-core/indexes.d.ts.map +1 -1
  111. package/dist/data/query/pg-core/indexes.js +7 -0
  112. package/dist/data/query/pg-core/indexes.js.map +1 -1
  113. package/dist/data/schema/json-schema.js +29 -1
  114. package/dist/data/schema/json-schema.js.map +1 -1
  115. package/dist/data/schema/types.d.ts +2 -2
  116. package/dist/data/schema/types.d.ts.map +1 -1
  117. package/dist/index.d.ts +18 -9
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +5 -1
  120. package/dist/index.js.map +1 -1
  121. package/dist/infra/alerts/handle.d.ts +33 -5
  122. package/dist/infra/alerts/handle.d.ts.map +1 -1
  123. package/dist/infra/alerts/handle.js +56 -12
  124. package/dist/infra/alerts/handle.js.map +1 -1
  125. package/dist/infra/alerts/http.d.ts +2 -2
  126. package/dist/infra/alerts/http.d.ts.map +1 -1
  127. package/dist/infra/alerts/http.js +21 -3
  128. package/dist/infra/alerts/http.js.map +1 -1
  129. package/dist/infra/alerts/index.d.ts +1 -1
  130. package/dist/infra/alerts/index.d.ts.map +1 -1
  131. package/dist/infra/alerts/schema.d.ts +5 -0
  132. package/dist/infra/alerts/schema.d.ts.map +1 -1
  133. package/dist/infra/alerts/schema.js +1 -0
  134. package/dist/infra/alerts/schema.js.map +1 -1
  135. package/dist/infra/crawl/audit.d.ts +6 -0
  136. package/dist/infra/crawl/audit.d.ts.map +1 -0
  137. package/dist/infra/crawl/audit.js +38 -0
  138. package/dist/infra/crawl/audit.js.map +1 -0
  139. package/dist/infra/crawl/errors.d.ts +14 -0
  140. package/dist/infra/crawl/errors.d.ts.map +1 -0
  141. package/dist/infra/crawl/errors.js +22 -0
  142. package/dist/infra/crawl/errors.js.map +1 -0
  143. package/dist/infra/crawl/handle.d.ts +149 -0
  144. package/dist/infra/crawl/handle.d.ts.map +1 -0
  145. package/dist/infra/crawl/handle.js +550 -0
  146. package/dist/infra/crawl/handle.js.map +1 -0
  147. package/dist/infra/crawl/index.d.ts +7 -0
  148. package/dist/infra/crawl/index.d.ts.map +1 -0
  149. package/dist/infra/crawl/index.js +5 -0
  150. package/dist/infra/crawl/index.js.map +1 -0
  151. package/dist/infra/crawl/links.d.ts +9 -0
  152. package/dist/infra/crawl/links.d.ts.map +1 -0
  153. package/dist/infra/crawl/links.js +194 -0
  154. package/dist/infra/crawl/links.js.map +1 -0
  155. package/dist/infra/crawl/namespace.d.ts +36 -0
  156. package/dist/infra/crawl/namespace.d.ts.map +1 -0
  157. package/dist/infra/crawl/namespace.js +36 -0
  158. package/dist/infra/crawl/namespace.js.map +1 -0
  159. package/dist/infra/crawl/robots.d.ts +16 -0
  160. package/dist/infra/crawl/robots.d.ts.map +1 -0
  161. package/dist/infra/crawl/robots.js +98 -0
  162. package/dist/infra/crawl/robots.js.map +1 -0
  163. package/dist/infra/crawl/schema.d.ts +222 -0
  164. package/dist/infra/crawl/schema.d.ts.map +1 -0
  165. package/dist/infra/crawl/schema.js +84 -0
  166. package/dist/infra/crawl/schema.js.map +1 -0
  167. package/dist/infra/credit-line/handle.d.ts +250 -0
  168. package/dist/infra/credit-line/handle.d.ts.map +1 -0
  169. package/dist/infra/credit-line/handle.js +618 -0
  170. package/dist/infra/credit-line/handle.js.map +1 -0
  171. package/dist/infra/credit-line/index.d.ts +3 -0
  172. package/dist/infra/credit-line/index.d.ts.map +1 -0
  173. package/dist/infra/credit-line/index.js +2 -0
  174. package/dist/infra/credit-line/index.js.map +1 -0
  175. package/dist/infra/credit-line/namespace.d.ts +7 -0
  176. package/dist/infra/credit-line/namespace.d.ts.map +1 -0
  177. package/dist/infra/credit-line/namespace.js +7 -0
  178. package/dist/infra/credit-line/namespace.js.map +1 -0
  179. package/dist/infra/credit-line/schema.d.ts +194 -0
  180. package/dist/infra/credit-line/schema.d.ts.map +1 -0
  181. package/dist/infra/credit-line/schema.js +89 -0
  182. package/dist/infra/credit-line/schema.js.map +1 -0
  183. package/dist/infra/email/inbound.d.ts +14 -2
  184. package/dist/infra/email/inbound.d.ts.map +1 -1
  185. package/dist/infra/email/inbound.js +5 -1
  186. package/dist/infra/email/inbound.js.map +1 -1
  187. package/dist/infra/email/index.d.ts +2 -2
  188. package/dist/infra/email/index.d.ts.map +1 -1
  189. package/dist/infra/email/index.js.map +1 -1
  190. package/dist/infra/email/mail.d.ts +43 -5
  191. package/dist/infra/email/mail.d.ts.map +1 -1
  192. package/dist/infra/email/mail.js +124 -9
  193. package/dist/infra/email/mail.js.map +1 -1
  194. package/dist/infra/email/mime.d.ts +6 -0
  195. package/dist/infra/email/mime.d.ts.map +1 -1
  196. package/dist/infra/email/mime.js +10 -2
  197. package/dist/infra/email/mime.js.map +1 -1
  198. package/dist/infra/email/schema.d.ts +17 -3
  199. package/dist/infra/email/schema.d.ts.map +1 -1
  200. package/dist/infra/email/schema.js +15 -4
  201. package/dist/infra/email/schema.js.map +1 -1
  202. package/dist/infra/email/sns.js +2 -2
  203. package/dist/infra/email/sns.js.map +1 -1
  204. package/dist/infra/email/unsubscribe.d.ts +13 -0
  205. package/dist/infra/email/unsubscribe.d.ts.map +1 -0
  206. package/dist/infra/email/unsubscribe.js +43 -0
  207. package/dist/infra/email/unsubscribe.js.map +1 -0
  208. package/dist/infra/fetch/handle.d.ts +20 -1
  209. package/dist/infra/fetch/handle.d.ts.map +1 -1
  210. package/dist/infra/fetch/handle.js +60 -28
  211. package/dist/infra/fetch/handle.js.map +1 -1
  212. package/dist/infra/fetch/index.d.ts +2 -2
  213. package/dist/infra/fetch/index.d.ts.map +1 -1
  214. package/dist/infra/fetch/index.js +1 -1
  215. package/dist/infra/fetch/index.js.map +1 -1
  216. package/dist/infra/fetch/namespace.d.ts +20 -2
  217. package/dist/infra/fetch/namespace.d.ts.map +1 -1
  218. package/dist/infra/fetch/namespace.js +20 -2
  219. package/dist/infra/fetch/namespace.js.map +1 -1
  220. package/dist/infra/join-requests/handle.d.ts +86 -0
  221. package/dist/infra/join-requests/handle.d.ts.map +1 -0
  222. package/dist/infra/join-requests/handle.js +233 -0
  223. package/dist/infra/join-requests/handle.js.map +1 -0
  224. package/dist/infra/join-requests/index.d.ts +3 -0
  225. package/dist/infra/join-requests/index.d.ts.map +1 -0
  226. package/dist/infra/join-requests/index.js +2 -0
  227. package/dist/infra/join-requests/index.js.map +1 -0
  228. package/dist/infra/join-requests/namespace.d.ts +7 -0
  229. package/dist/infra/join-requests/namespace.d.ts.map +1 -0
  230. package/dist/infra/join-requests/namespace.js +7 -0
  231. package/dist/infra/join-requests/namespace.js.map +1 -0
  232. package/dist/infra/join-requests/schema.d.ts +58 -0
  233. package/dist/infra/join-requests/schema.d.ts.map +1 -0
  234. package/dist/infra/join-requests/schema.js +31 -0
  235. package/dist/infra/join-requests/schema.js.map +1 -0
  236. package/dist/infra/outbox/core.d.ts +9 -0
  237. package/dist/infra/outbox/core.d.ts.map +1 -1
  238. package/dist/infra/outbox/core.js +14 -0
  239. package/dist/infra/outbox/core.js.map +1 -1
  240. package/dist/infra/support/handle.d.ts +43 -0
  241. package/dist/infra/support/handle.d.ts.map +1 -1
  242. package/dist/infra/support/handle.js +35 -7
  243. package/dist/infra/support/handle.js.map +1 -1
  244. package/dist/infra/support/http.d.ts +28 -0
  245. package/dist/infra/support/http.d.ts.map +1 -0
  246. package/dist/infra/support/http.js +217 -0
  247. package/dist/infra/support/http.js.map +1 -0
  248. package/dist/infra/support/index.d.ts +2 -0
  249. package/dist/infra/support/index.d.ts.map +1 -1
  250. package/dist/infra/support/index.js +1 -0
  251. package/dist/infra/support/index.js.map +1 -1
  252. package/dist/infra/support/namespace.d.ts +3 -0
  253. package/dist/infra/support/namespace.d.ts.map +1 -1
  254. package/dist/infra/support/namespace.js +3 -0
  255. package/dist/infra/support/namespace.js.map +1 -1
  256. package/dist/infra/support/schema.d.ts +4 -4
  257. package/dist/infra/webhook/outbound.d.ts +16 -0
  258. package/dist/infra/webhook/outbound.d.ts.map +1 -1
  259. package/dist/infra/webhook/outbound.js +78 -27
  260. package/dist/infra/webhook/outbound.js.map +1 -1
  261. package/dist/request/di/builder.d.ts +24 -1
  262. package/dist/request/di/builder.d.ts.map +1 -1
  263. package/dist/request/di/builder.js +35 -0
  264. package/dist/request/di/builder.js.map +1 -1
  265. package/dist/request/di/types.d.ts +30 -0
  266. package/dist/request/di/types.d.ts.map +1 -1
  267. package/dist/request/http/server.d.ts.map +1 -1
  268. package/dist/request/http/server.js +111 -20
  269. package/dist/request/http/server.js.map +1 -1
  270. package/dist/test/auto-setup.js +2 -2
  271. package/dist/test/auto-setup.js.map +1 -1
  272. package/dist/test/crawl-fixture.d.ts +53 -0
  273. package/dist/test/crawl-fixture.d.ts.map +1 -0
  274. package/dist/test/crawl-fixture.js +130 -0
  275. package/dist/test/crawl-fixture.js.map +1 -0
  276. package/dist/test/index.d.ts +2 -1
  277. package/dist/test/index.d.ts.map +1 -1
  278. package/dist/test/index.js +2 -1
  279. package/dist/test/index.js.map +1 -1
  280. package/dist/test/progress.d.ts +7 -1
  281. package/dist/test/progress.d.ts.map +1 -1
  282. package/dist/test/progress.js +6 -0
  283. package/dist/test/progress.js.map +1 -1
  284. package/dist/test/reap-after-run.d.ts +3 -0
  285. package/dist/test/reap-after-run.d.ts.map +1 -0
  286. package/dist/test/reap-after-run.js +29 -0
  287. package/dist/test/reap-after-run.js.map +1 -0
  288. package/dist/test/reap.d.ts +11 -2
  289. package/dist/test/reap.d.ts.map +1 -1
  290. package/dist/test/reap.js +19 -12
  291. package/dist/test/reap.js.map +1 -1
  292. package/dist/test/setup.d.ts +2 -0
  293. package/dist/test/setup.d.ts.map +1 -1
  294. package/dist/test/setup.js +97 -31
  295. package/dist/test/setup.js.map +1 -1
  296. package/dist/test/vitest-plugin.d.ts.map +1 -1
  297. package/dist/test/vitest-plugin.js +8 -1
  298. package/dist/test/vitest-plugin.js.map +1 -1
  299. package/dist/test/vitest-workspace.d.ts +1 -0
  300. package/dist/test/vitest-workspace.d.ts.map +1 -1
  301. package/dist/test/vitest-workspace.js +1 -1
  302. package/dist/test/vitest-workspace.js.map +1 -1
  303. package/dist/test/vitest.d.ts +20 -0
  304. package/dist/test/vitest.d.ts.map +1 -1
  305. package/dist/test/vitest.js +41 -1
  306. package/dist/test/vitest.js.map +1 -1
  307. package/dist/trace/partition/sync.d.ts +8 -2
  308. package/dist/trace/partition/sync.d.ts.map +1 -1
  309. package/dist/trace/partition/sync.js +6 -3
  310. package/dist/trace/partition/sync.js.map +1 -1
  311. package/dist/workspace/generate.d.ts.map +1 -1
  312. package/dist/workspace/generate.js +1 -0
  313. package/dist/workspace/generate.js.map +1 -1
  314. package/package.json +1 -1
  315. package/dist/infra/email/sigv4.d.ts +0 -24
  316. package/dist/infra/email/sigv4.d.ts.map +0 -1
  317. package/dist/infra/email/sigv4.js +0 -55
  318. package/dist/infra/email/sigv4.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.243.0
4
+
5
+ - **Fixed: test setup failed with "No progress" while a long migration file was still applying** (#871). The template build reported progress only after each migration file committed, so a file whose statements together ran past `test.setupStallMs` on a loaded box (verity: 120+ s at `template-migrate`) failed the run. Setup now beats after every statement of a file (`template-migrate` detail `i/n <tag> stmt s/n`), before the first file (`start`, then `prepare <step>` for the tracker table, legacy tag recovery per file, the tag index and the applied read), and on each template-build step (`drop`, `create`, `owner`, `platform role`) and framework table statement. A single statement that hangs still trips the watchdog; its thresholds are unchanged. `applyMigrations` takes `onStatement(tag, statement, statements, index, total)` and `onPrepare(detail)`.
6
+
7
+ ## 0.242.0
8
+
9
+ - **Fixed: `webhook.deliver` could reach the app's private network** (#866). Deliveries went through the global `fetch` to the tenant-registered URL, with no address check and redirects followed, and the response was kept on the delivery row. They now go through the new `untrustedFetch.post`: every resolved address checked at connect (loopback, private, link-local and cloud metadata, CGNAT, etc. refused), no redirect followed, the kept response body capped at 64 KiB. A refused attempt is a failed attempt, retried as before, and its reason (`address`, `scheme`, `redirects`, `network`, `timeout`) is recorded in a new nullable `error` column of the delivery table (`ensureTables` adds it to an existing table). **Changed:** an app whose webhooks go to its own internal services sets `createOutbound({ addresses: 'any' })` (plain fetch, still no redirects and the cap), which throws when `environment: 'production'` is passed. A non-2xx answer's body is no longer kept (`response_body` null; `status_code` recorded). `network: { resolve, connect }` is for tests.
10
+ - **Added: `untrustedFetch.post(url, { body, headers, maxBytes, timeoutMs, maxRedirects, ... })`** (#866): `get`'s rules for a POST; a 307/308 resends the body, 301/302/303 become a GET without it. Trace step `pipework/fetch:post`.
11
+ - **Fixed: SNS subscription and certificate fetches followed redirects** (#866): both now use `redirect: 'error'`.
12
+
13
+ ## 0.241.0
14
+
15
+ - **Added: `crawl`, a polite, sandboxed site crawler that runs as a durable job** (#865). Turn it on with a `crawl: { requireApproval? }` Blueprint block (which also opts the `pipework_crawl*` tables into generated migrations); without it `crawl.create` throws `CrawlNotEnabledError`. `const site = crawl.create({ instance, name, queue, accept, userAgent, onPage?, store? })`; add `site.job` to a worker surface's `jobs`. `site.start({ seed, scope?, limits? })` records a crawl (one audit row) and queues it, or leaves it `awaiting_approval` until `site.approve(id)` when `requireApproval` is set. `accept({ url, contentType })` says what is listed; in-scope HTML is followed for links. robots.txt is read first per host and obeyed (RFC 9309: groups, longest match, `*`/`$`, `Crawl-delay`; 4xx allows all, 5xx/unreachable retried then stops with `robots_unreachable`). Per-host concurrency (1) and gap (max of `minGapMs` 1000 and `Crawl-delay`); limits on pages requested, depth, total bytes, bytes per response, wall time, request time, redirects and `onPage` time, and a crawl over one ends `stopped` with `stoppedBy` naming it. Every request goes through `untrustedFetch.get` (address rules at connect); each redirect is queued as its own URL and re-checked against scope and robots. Scope is the seed's host (a redirect of the seed itself to another host, e.g. `www.`, moves it) or `{ domain }`. Links come from a linear scanner that cannot execute anything. The frontier is in the database: a dead job resumes without refetching completed URLs (the one in flight may be fetched again); a later crawl of the same seed sends `If-None-Match`/`If-Modified-Since` and records a 304 as `unchanged`. `store` puts listed bytes into `storage` by sha256. `status`, `resources` and `refusals` read a crawl, only from its own tenant. One `pipework/fetch:get` trace step per request. `pipework/test` exports `crawlFixtureSite({ root, hosts, routes? })`, a directory served as a site under public-looking names, with redirects, delays, generated large bodies and a request log.
16
+ - **Added: `untrustedFetch.get({ followRedirect })` and `etag` / `lastModified` on its ok result** (#865). `followRedirect(to, from)` returning false or a message refuses the hop as `rejected`.
17
+
18
+ ## 0.240.0
19
+
20
+ - **Fixed: an exported `.appendOnlyVersions()` table fails declaration emit** (#863, TS2883). `AppendOnlyVersionsFields` is now exported from the package root, beside `EffectiveDatedFields`. Also newly root-exported, type-only, for the same reason: `FieldKind`, `FieldFlags`, `FieldRecord`, `ForeignKeyAction`, `CompositeForeignKey`, `PartitionSpec`, and `InsertProjection` / `SelectProjection` / `UpdateProjection` with zod's `ZodType` / `$ZodTypeInternals`, so an exported `insertShape()` / `selectShape()` / `updateShape()` result also emits.
21
+
22
+ ## 0.239.0
23
+
24
+ - **Added: `alerts.raise({ resolveAfterQuietMs: null })` and `resendAfterMs: null`** (#861): `null` opts one alert out of the handle's quiet period (only `resolve` closes it) or its re-send period (never re-sent while open). A number still overrides the handle's value; omitted still falls back to it. The choice holds across raises while the alert stays open and clears when it reopens. A negative or non-integer value is refused. No schema change: `null` is stored as `-1` in the existing columns.
25
+ - **Changed: a quiet resolve records itself** (#861): `resolvedBy` is `'quiet'` and `resolveNote` is `'quiet for <ms> ms'` (the period that applied); its audit row carries `by: 'quiet'`. Before, both were empty.
26
+
27
+ ## 0.238.0
28
+
29
+ - **Fixed: a response schema with a lazy reference to a recursive schema stops the app booting** (#859). `toJsonSchema` now writes zod's `{ allOf: [{ $ref }] }` for a `z.lazy(() => node)` field as a bare `$ref` (other keys on the node, such as `description`, kept). Fastify's serializer looped forever on the `allOf` form when `node` was recursive (`Failed building the serialization schema ... Maximum call stack size exceeded`). The same output feeds the OpenAPI document. An `allOf` with more than one member, or a member that is more than a `$ref`, is unchanged.
30
+
31
+ ## 0.237.0
32
+
33
+ - **Added: `support.requeueFailed` and `alerts.requeueFailed`** (#856): `({ by, sink?, failedSince? }) => { requeued }` puts the outbox's given-up deliveries (`failed_at` set, never delivered) back on the queue, across tenants, optionally only one sink's and/or those that failed at or after `failedSince`: `failed_at` cleared, `attempts` 0, due now; the next `tick` sends them. Delivery ids stay the same, so a receiver that dedupes on `delivery.id` takes each once. `by` (who asked) is required; each requeued delivery gets a `requeue` audit row with `by`, the attempts it had and its last error. It replaces hand SQL on `support_event_delivery` / `pipework_alert_deliveries` for answering `delivery-failed:<table>:<sink>`, which resolves after the resent deliveries go through. Like the other alerts audit rows, `alerts.requeueFailed`'s are written under a traced Flow.
34
+
35
+ ## 0.236.0
36
+
37
+ - **Added: `instance.pool.connectionBudget(name)`** (#854): the connections a process may still open on a database's server without starving other pipework processes. Returns `{ maxConnections, reserved, held, othersUnopened, free }`: `reserved` is `superuser_reserved_connections` + `reserved_connections`, read from the server; `held` the client backends open now; `othersUnopened` each other live publishing process's reservation (the sum of its pools' `max`) minus what it holds; `free` what is left. Opt in per database with `pool: { publishReservation: true }`: each process then keeps one presence connection per server whose `application_name` is `pipework:<id>:<reservation>`, updated as pools are created and closed, and tags its pool connections `pipework:<id>` (a `pool.settings.application_name` of yours wins, and those connections are then not attributed). A process's reservation counts before its pools open a connection, and leaves `pg_stat_activity` with the process. No table, no superuser. **Limits:** a non-superuser cannot see other roles' `backend_type`, so `held` also counts their background workers, so `free` reads low rather than high. Each publishing process's presence connection is one more held connection.
38
+
39
+ ## 0.235.0
40
+
41
+ - **Added: `mail.inbound({ attachmentRetentionDays })`** (#852). Each attachment stored through `storage` expires that many days after the message was received (from the inbound row's `received_at`, so a retried receive gives the same expiry), and the storage handle's `sweep` deletes the row and the object. A whole number of days, one or more (fractions round down; less is refused at create time). Unset, attachments have no expiry, as before.
42
+
43
+ ## 0.234.0
44
+
45
+ - **Added: an http surface's OpenAPI document without an instance** (#848). `httpSurface.openapi()` builds the document from the surface's handlers, resources and `openapi` info, with no context and no I/O (`ConfigError` without `openapi` info). `pipework openapi [--surface <name>] [--out <file>]` loads the config, connects to nothing, and writes it (stdout without `--out`); without `--surface` exactly one http surface with `openapi` info must exist. The output is `JSON.stringify` of the document, byte-for-byte what `GET /openapi.json` serves: the server and `openapi()` register from one route list, so the spec can be a build output instead of a committed file.
46
+
47
+ ## 0.233.0
48
+
49
+ - **Fixed: a received mail with an ambiguous author names none** (#847). With several `From:` fields, or several mailboxes in one `From:`, `ReceivedEmail.from` is the empty address and the new `fromProblem` says why (`'several-from-fields'` or `'several-mailboxes'`; `null` otherwise; type `FromProblem` on the root). Before, the first field's first mailbox was named, so a DMARC PASS for one domain could sit beside a `from` in another. DMARC vouches for `from` only when `fromProblem` is null and `from.address` is non-empty (RFC 7489 §6.6.1). A quoted display name with a comma is still one mailbox.
50
+
51
+ ## 0.232.0
52
+
53
+ - **Changed: migrations are named by UTC timestamp and need no committed journal** (#846). `pipework generate` names a new migration `YYYYMMDDHHMMSS_<name>.sql` and writes `meta/<tag>.delta.json` beside it: the schema change that migration made. It no longer writes `meta/_journal.json` or `meta/NNNN_snapshot.json`, so two branches that each add a migration add only new files and merge without conflict. `migrate`, `migrate check` and `generate` take the order from the folder's `.sql` files: existing `NNNN_` migrations first, by index, then timestamped ones by time. The previous schema is the last `NNNN_snapshot.json` the old journal names, folded with each timestamped migration's delta. Existing `NNNN_` files, the journal and snapshots stay as they are (the journal still supplies the old migrations' times, and a tag it lists without a `.sql` is an error). A tracker that holds `0000`–`NNNN` applies only the new files. **Note:** every `.sql` in the migrations folder is now a migration (a file not named `NNNN_…` or `YYYYMMDDHHMMSS_…` is an error), and a `generate` that emits nothing writes nothing. Two branches that change the same column or index of one table still need the later one regenerated.
54
+
55
+ ## 0.231.0
56
+
57
+ - **Added: `pipework serve --worker-only` and `manifold.start({ kinds })`** (#842). A worker-only process starts only the app's `worker` surfaces: it opens no port (`--port` with it is an error) and stops on SIGTERM like `serve`, so workers scale apart from the API against one queue. It does not run `script` surfaces (migrations, seeds), which belong to the main process. `start({ kinds: ['worker'] })` does the same in code; every surface is still validated, and a filter that matches none is a `ConfigError` naming the declared surfaces. The CLI passes the filter as `PIPEWORK_SURFACE_KINDS`.
58
+
59
+ ## 0.230.0
60
+
61
+ - **Added: `support.create({ joinThread })`** (#840). `'contact'` (default) is unchanged: only the ticket's contact joins it by thread. `'conversation'`: a message threaded into a ticket's conversation (or sent to it with `ticketId`) joins it and reopens it whoever sent it; the message keeps its own sender, the ticket's contact and user stay, and only the contact's own message can fill a null `user_id`. The `receive` audit carries `joinedBy: 'conversation'` when someone else joined. A `separate`-screened message still opens its own ticket. Risk: anyone holding a reply's Message-ID can add to the ticket; pair it with a strict `screen`.
62
+
63
+ ## 0.229.0
64
+
65
+ - **Fixed: `RECEIVE_JOB` and `SEND_JOB` are exported from the package root** (#841), so a worker's job map can name the email jobs (`'pipework.email.receive'`, `'pipework.email.send'`). `ReceivedEmail`, `Inbound`, `InboundSource` and the other inbound types have been on the root since 0.223.0.
66
+
67
+ ## 0.228.0
68
+
69
+ - **Added: `support.http(handle, { path, credentials })`** (#835): an HTTP API for a support agent, shaped like `alerts.http`. Bearer keys, `name -> secret` (a drafting key) or `name -> { secret, approve: true }`; the name is the actor in audit rows, a draft's author and the approver. Routes: `GET /` (`state`, `category`, `tenantId`, `limit`, `cursor`), `GET /:id`, `PATCH /:id` (`category`), `POST /:id/transition`, `/:id/notes`, `/:id/links`, `/:id/drafts`, `PATCH` / `DELETE /drafts/:id`, `POST /drafts/:id/approve` (`{ version }`). No key 401, a key without `approve` 403 before anything runs, `canApprove` / `distinctApprover` 403, a stale or sent draft 409, a bad field 400. **Added:** `support.listAll({ state?, category?, tenantId?, limit?, cursor? })`, tickets across tenants. **Limit:** ids and lists are read across tenants on the system channel, which a database with `rls.platformRole` refuses.
70
+
71
+ ## 0.227.0
72
+
73
+ - **Added: `support.create({ resolveContact })`** (#836): `({ address, name, tenantId }, tx) => Promise<{ userId } | null>` links a ticket to the sender's user. Called inside the receive's tenant transaction by `receiveEmail`, and by `receive` when no `contact.userId` is given: a new ticket gets the user in its insert (the `create` audit carries `userId`); a message joining a ticket whose `user_id` is null fills it (`userIdSet` on the `receive` audit). A non-null `user_id` is never changed. A throw rolls the receive back. Without it, behaviour is unchanged.
74
+
75
+ ## 0.226.0
76
+
77
+ - **Added: composite foreign keys can clear only some columns on delete** (#834). `foreignKeys: [{ columns: ['tenantId', 'documentId'], references: { definition: Document, columns: ['tenantId', 'id'] }, onDelete: { setNull: ['documentId'] } }]` (or `{ setDefault: [...] }`) emits `ON DELETE SET NULL ("document_id")`, so deleting the document keeps the row and its tenant. Needs Postgres 15+. The list must be a non-empty subset of the key's columns, checked at define time. It is carried in the snapshot: switching an existing key to or from a column list drops and re-adds the constraint; keys without a list are unchanged and generate nothing.
78
+
79
+ ## 0.225.0
80
+
81
+ - **Added: postpaid credit line per tenant** (#831). `creditLine.create({ warnShare?, timeZone?, onLineCrossed? })`, the arrears counterpart of prepaid credits, in whole cents. `openLine({ ceilingCents, caps?, warnShare? })` / `closeLine()`; `reserve` / `settle` / `release` by job id, as credits has them: a reserve fails past `ceiling - unpaid - held`, or past any cap (`{ name, cents, window: 'calendar_year' | 'calendar_month' | { from, to } }`, settled-in-window plus held), naming the limit; settle never fails on limits. `authorize({ reference, amountCents, from, to, replacesCaps? })` (a purchase order) replaces the ceiling, and the caps it names, while in force; one at a time; `authorizations()` lists what's left. `onLineCrossed({ limit, usedCents, limitCents, warnAtCents })` is awaited inside the write that takes a limit past its warn share (default 0.8), once per crossing. `recordPayment({ amountCents, reference })` clears unpaid entries oldest first, idempotent by reference, excess kept for later work. `statement({ from, to })` lists the period's entries and payments with totals. `line()` reads the state. Each call has a `...For(tenantId, ...)` form and is audited as `CreditLine`. **Migration:** set `creditLine: {}` and run `pipework generate`; it adds the `credit_line*` tables.
82
+
83
+ ## 0.224.0
84
+
85
+ - **Added: join requests** (#830). `joinRequests.create({ isOwner, addMember, expiresInMs?, onRequested?, onSettled? })`: a user asks to join an existing org (`request(orgId, userId, { reason, evidence })`, one open request per user and org, a second returns the first) and an owner `approve`s or `refuse`s it; the joiner can `withdraw`. Approval calls the app's `addMember` in the same transaction, so a failed add leaves the request open. States `open -> approved | refused | withdrawn | expired`, with who settled it and when; expiry defaults to 30 days and `sweep()` marks lapsed ones (schedule it). `list({ status })` reads the Flow tenant's requests; `mine(userId)` reads one user's across orgs. Hooks are awaited inside the step's transaction; a throw rolls it back. Each step is a `JoinRequest` audit record on the org's chain (without `evidence`). **Migration:** set `joinRequests: {}` and run `pipework generate`; it adds `join_request`. **Limit:** `mine`, `withdraw` and `sweep` read across tenants on the system channel, which a database with `rls.platformRole` refuses.
86
+
87
+ ## 0.223.0
88
+
89
+ - **Fixed: types missing from the package root** (#828). `LowBalanceState` and `LowBalanceCrossing` (credits) are now exported from `pipework`, as are the other public types a subsystem exported but the root left out: alerts `AlertOwnerChange` (`OwnerChange`), `ClientFaultReport`, `AlertRequestFaultsOptions`; mail inbound `Inbound`, `InboundConfig`, `InboundSource`, `InboundRecord`, `InboundScanResult`, `InboundSweepResult`, `ReceivedEmail`, `ReceivedAttachment`, `EmailAddress`, `EmailVerdicts`, `UnthreadedEmail`, `SesReceiptConfig`; access links `AccessLinkCodeConfig` (`CodeConfig`). Type-only; no runtime change.
90
+
91
+ ## 0.222.0
92
+
93
+ - **Added: `pipe.filter.inList(expr, values, cast?)` and `notInList`** (#825): list membership on the filter surface, bound as one array parameter, `expr = ANY($1::<type>[])` / `expr <> ALL($1::<type>[])`, which Postgres hashes. Unlike `inArray` (one parameter per element, failing past 65,535) it takes any length, and unlike a join on `unnest` or `arrayAny` it plans no subquery. An empty list is `false` / `true`. Strings bind as `text`, integers as `bigint`, other numbers as `numeric`; pass `cast` for other columns, e.g. `inList(t.id, ids, 'uuid')`. Mixed types, null, NaN or a bad cast throw.
94
+
95
+ ## 0.221.0
96
+
97
+ - **Added: acknowledging an alert can name its owner** (#824). `alerts.acknowledge(key, { by, note?, owner? })` and `POST /ops/alerts/:key/acknowledge` with `owner` replace the stored owner and append `{ from, to, by, at }` to the alert's new `ownerHistory` (nothing when it is already the owner). An acknowledged alert given a different `owner` changes the owner only, keeping who acknowledged it and when. An owner set this way survives later raises and reopens; the raising code's owner applies only until then. `alerts.list({ owner })` and `GET /ops/alerts?owner=a,b` filter on the current owner. An empty owner is refused (400 over HTTP). **Added:** `OwnerChange` type. **Migration:** run `pipework generate`; it adds `owner_history jsonb NOT NULL DEFAULT '[]'` to `pipework_alerts`.
98
+
99
+ ## 0.220.0
100
+
101
+ - **Added: one-click unsubscribe per list for mail** (#821). `send` takes `list` (e.g. `'notice-digest'`); such a message carries RFC 8058 `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers with a signed, non-expiring link, and is skipped at delivery (status `unsubscribed`, no provider call, `Email:unsubscribed` audit) when its address has opted out of that list. Mail with no list is never affected. `MailConfig.unsubscribe: { url, secret }` (secret at least 32 characters; url https in production) is required for a send with a list. `mail.unsubscribeHttp()` serves the link's routes: GET a confirm page, POST the one-click opt-out (form-urlencoded, multipart or empty body, never read). `mail.unsubscribe` / `unsubscribed` / `resubscribe` back an app's settings page. Templates get `{ unsubscribeUrl }` as a second argument (null with no list). **Added:** `Builder.ignoreBody()` for a route that takes any body unread; `EmailRenderMeta` type. **Migration:** run `pipework generate`; it adds the `pipework_email_unsubscribes` table, the `list` column on `pipework_email_messages`, and `unsubscribed` to its status check.
102
+
103
+ ## 0.219.0
104
+
105
+ - **Added: `/ready` and `/health` name the release** (#820). Both bodies carry `release: string | null`, which is `manifold.release`, the same value written on traces and job claims; null when no `release` block is configured or its variable is unset. A deploy can poll `/ready` until it answers with the release it stamped. `HealthStatus` gains `release`.
106
+
107
+ ## 0.218.0
108
+
109
+ - **Added: `useTestClient(surface, context?, { before? })` in `pipework/test`** (#818). It makes the test client in its own `beforeAll` (after `before`, for app setup such as services and sinks) and closes it in its own `afterAll` only if it was made, so a file whose setup fails reports only that error, not a second `Cannot read properties of undefined (reading 'close')`. It replaces `let client; beforeAll(async () => { client = await api.test(useTestInstance()) }); afterAll(() => client.close())`. `context` defaults to `useTestInstance`. The returned `TestClient` forwards `inject` to the client made; `inject` rejects with a `[pipework]` error naming the case when the client was not made or is closed. `close()` may be called early, and the `afterAll` then does nothing.
110
+
111
+ ## 0.217.0
112
+
113
+ - **Changed: the generated `tsconfig.base.json` turns on `noUnusedLocals`** (#816), so an unused import or local fails `pnpm type-check` and `pipework check` with TS6133, and `pipework sync` keeps it. `noUnusedParameters` stays off. A module turns either off or on through its `tsconfig` field in `pipework.config.ts`, which is merged over the base; for every module under a directory use a glob key, e.g. `modules: { 'packages/*': { tsconfig: { noUnusedLocals: false } } }`, and an explicit module key overrides the glob. **Migration:** run `pipework sync`; remove the unused locals it reports, or opt the module out as above.
114
+
115
+ ## 0.216.0
116
+
117
+ - **Added: `.onRefused(hook)` on a route, called once for every 4xx it answers** (#814), including the refusals pipework makes before the handler runs: the input/params/query `VALIDATION_ERROR` (422), malformed JSON (400), auth 401, permission 403, rate-limit 429, body-limit 413 and upload 413/415, and the handler's own thrown 4xx. It is never called for a 5xx, the route's own `screen` refusal, or a URL no route matches. The hook gets `{ status, code, message, fields, auth, params, query, request, requestId }` (`auth` is null where auth refused). A plain object it returns is merged into the response's `error`, so a route can add `escalated: true`; `code`, `message`, `fields` and `details` keep pipework's values (a hook that sets one is warned and dropped). A throw is logged and the refusal goes out unchanged. It runs in the request's Flow as the trace step `pipework/http:on-refused` after `http.error`, once the request's transaction has rolled back. The upload `onRefuse` hook now also runs before the trace closes. **Added:** `RouteRefusal`, `RouteRefusedHook` types.
118
+
119
+ ## 0.215.2
120
+
121
+ - **Fixed: a template build whose app role failed to create left a template every later run cloned** (#812). The marker that makes a template clonable was written before the app role, its grants and the test-system policies, so a refused `CREATE ROLE` left a marked template and each later run failed at `GRANT CONNECT … TO "<app role>"` (role does not exist) until the template was dropped by hand. The marker is now written last. A marked template is reused only while its app role and `rls.platformRole` exist on the cluster; otherwise it is rebuilt, which also repairs templates left by earlier versions. A `CREATE ROLE` refused for lack of privilege now throws `[pipework] The test login "<login>" needs CREATEROLE on the test cluster to make role "<role>"`.
122
+
123
+ ## 0.215.1
124
+
125
+ - **Fixed: a forked test run left its workers' last queued clones in Postgres** (#810). The final drop ran on the fork's `disconnect`, which vitest 4 never sends (it SIGTERMs the fork after the stop handshake), so each fork left up to `dropBatchSize - 1` clones for the next run's reaper. The pipework vitest plugin now adds a `globalSetup` whose teardown runs in the parent after every worker has stopped and drops clones whose run lock is free (never templates, never a concurrent run's live clones), naming them on one `pipework:test-reap` stderr line. Skipped with `pipework({ database: false })`. Best effort. **Added:** `reapOrphanTestDatabases`'s `clonesOnly` option, and `reapConfiguredTestDatabases(instance, options)`.
126
+
127
+ ## 0.215.0
128
+
129
+ - **Fixed: the orphan reap in test setup made no progress the stall watchdog could see** (#808). `run-prepare` reaps leftover test databases and stale templates one drop at a time; with dozens of orphans on a loaded host the run failed `SetupStalled` naming `run-prepare` while the reap was still working. A new phase `run-reap` beats before each candidate (detail `n/total name`, one total over clones and templates), so a hung drop is named. A clone's `CREATE DATABASE … WITH TEMPLATE` retry while the template is in use beats `clone-start` (detail `retry n`). **Added:** `reapOrphanTestDatabases(admin, liveTemplates, { onReap })` and `withTemplateRetry`'s `onRetry` option.
130
+
131
+ ## 0.214.0
132
+
133
+ - **Fixed: a template build's work after the last migration made no progress the stall watchdog could see** (#806). Framework tables, the audit chain, framework policies, day partitions (hundreds with `audit.retentionDays: 365`), the template marker, role grants and test-system policies ran under the last `template-migrate` beat, so a loaded host tripped `SetupStalled` naming `template-migrate` while setup was still working. Each now advances setup: new phases `template-framework`, `template-partitions` (one beat per partition as its CREATE starts, detail `n/total name`) and `template-grants` (per routine and per policy), and each extension beats `template-build`. A stall now names the step actually stuck. **Added:** `syncTracePartitions(db, config, now, { onPartition })` and `provisionFrameworkTables`'s `onProgress` option.
134
+
135
+ ## 0.213.1
136
+
137
+ - **Fixed: the test setup `beforeAll` was still bounded by vitest's 60 s `hookTimeout`** (#804). A template build that kept advancing on a loaded host was killed mid-build and restarted on the next run, so the stall watchdog (`test.setupStallMs`, #380) never got to judge it. The setup hook in `pipework/test/auto-setup` and in `setupPipeworkTests` now takes `SETUP_HOOK_TIMEOUT_MS` (setTimeout's ceiling), so setup fails only with `SetupStalled` when a phase stops advancing. Every other hook keeps the project `hookTimeout`.
138
+
139
+ ## 0.213.0
140
+
141
+ - **Added: `uniqueIndex(...).nullsNotDistinct()`** (#802). The index is created `NULLS NOT DISTINCT` (PG 15+), so NULLs in its columns compare equal and `ON CONFLICT (cols)` fires on them. Turning it on or off for an existing index migrates as an index replace: the new index is built `CONCURRENTLY` beside the old one, the old one dropped `CONCURRENTLY`, the new one renamed into its place, with no ACCESS EXCLUSIVE lock held for the build. Calling it on a non-unique index throws. Snapshots record the flag only when set, so existing schemas produce no diff. **Fixed: a unique constraint or composite primary key added to an existing table was built under ACCESS EXCLUSIVE.** It is now built as a unique index `CONCURRENTLY`, then attached with `ADD CONSTRAINT … USING INDEX`. A changed one has its new index built first and is swapped in one `ALTER TABLE … DROP CONSTRAINT old, ADD CONSTRAINT … USING INDEX` statement, so the key is never absent (when the old key's columns are dropped or change type, the old constraint is dropped first, as before). Partitioned tables keep the plain `ADD CONSTRAINT`. Each pipework-named concurrent build first drops a leftover index of its name, so a migration that failed (for example on existing duplicate rows, which must be removed before a `NULLS NOT DISTINCT` or new unique index can build) can be rerun. A unique constraint or primary key that shares its name with a declared index on the table now fails at `pipework generate`. **Migration:** run `pipework generate`; tables that had NULL-duplicate rows under the old index need them removed before the migration applies.
142
+
3
143
  ## 0.212.0
4
144
 
5
145
  - **Added: `enqueue({ pendingKey })` coalesces with a pending job and takes the newer payload** (#800). While a job on the queue with the same key and the same tenant is pending (not yet claimed), `enqueue` replaces that job's payload with this call's and returns its id; every other option of the call is ignored and the job keeps its place in line (queue time, priority, attempts, flow). Once the job is claimed, the next `enqueue` inserts a new job, so a re-render enqueued after a render started still runs. Not combinable with `liveKey`. Atomic across concurrent enqueuers: a partial unique index `uq_<table>_pending_key` on `(pending_key, coalesce(flow_tenant, ''))` over pending jobs, `INSERT … ON CONFLICT DO UPDATE`, under a transaction-scoped advisory lock per key and tenant (held to the enclosing transaction's commit). A claimed job that would return to pending (a retry after `failAttempt`/`fail`, `reap`, `handBack`, `later`) while a newer pending job holds its key is cancelled instead, its `error_payload` `{ reason: 'superseded', by: <id> }`, and `waitFor` wakes; `FailedAttempt.outcome`, `ReapedJob.outcome` and the `reaped` job event's `outcome` gain `'superseded'`. `requeueDeadLetter` of a job whose key a pending job holds throws naming it. `ClaimedJob.pendingKey` carries the key. **Migration:** queue tables gain a `pending_key` column and the index; run `pipework generate`.
package/REFERENCE.md CHANGED
@@ -29,7 +29,9 @@
29
29
  - [webhook — Inbound & Outbound Webhooks](#webhook-inbound-outbound-webhooks)
30
30
  - [mail — Transactional Email](#mail-transactional-email)
31
31
  - [accessLinks — Access Links](#accesslinks-access-links)
32
+ - [joinRequests — Join Requests](#joinrequests-join-requests)
32
33
  - [credits — Prepaid Usage Credits](#credits-prepaid-usage-credits)
34
+ - [creditLine — Postpaid Credit Line](#creditline-postpaid-credit-line)
33
35
  - [untrustedFetch — Fetch a URL a Stranger Gave](#untrustedfetch-fetch-a-url-a-stranger-gave)
34
36
  - [connectRetry — Retry a Call That Never Connected](#connectretry-retry-a-call-that-never-connected)
35
37
  - [storage — File Storage](#storage-file-storage)
@@ -724,6 +726,12 @@ Creates a worker surface — a long-running process that claims and executes job
724
726
 
725
727
  Creates a script surface — a one-shot process that runs and exits (migrations, seeds, CLI tools). The run is traced as a job-kind trace that names the surface.
726
728
 
729
+ ### Starting some surfaces: `manifold.start({ kinds })` and `pipework serve --worker-only`
730
+
731
+ `manifold.start({ port?, kinds? })`: `kinds` (`'http' | 'worker' | 'script'`) starts only the surfaces of those kinds and logs the skipped ones once at info level. Unset starts every surface. The whole surface map is still validated, the start `port` is checked against the surfaces being started only, and `stop()` covers only those. A filter that matches no surface throws a `ConfigError` naming the declared surfaces.
732
+
733
+ `pipework serve --worker-only` runs a job worker with no HTTP server, to scale workers apart from the API: it starts the `worker` surfaces only (the app's job handlers and the worker's own `concurrency`, classes and queue settings apply as in `serve`), opens no port, and stops cleanly on SIGTERM like `serve`. Several worker-only processes and one full `serve` share one database and one queue; a job held by a stopped worker is handed back and taken by another. `script` surfaces (migrations, seeds) are one-shot work that belongs to the main process, so a worker-only process does not run them and a scaled worker never repeats them. `--worker-only` with `--port` is an error. `dev` has no such flag. The flag sets `PIPEWORK_SURFACE_KINDS=worker` for the child; the variable takes a comma list of surface kinds, and an unknown kind fails at start.
734
+
727
735
  ## pipe — Database
728
736
 
729
737
  Database accessor — returns a Drizzle DB instance scoped to the current request/job/test context. Requires active AsyncLocalStorage context.
@@ -809,6 +817,9 @@ Expression helpers for `.set()`, `.values()` and `.where()` (a string or number
809
817
  **`filter.arrayAny(source, test)`** — true when some element of the array satisfies the test: `EXISTS (SELECT 1 FROM unnest(<source>) AS pw_el_<n>(v) WHERE <test>)`. False for an empty array.
810
818
  **`filter.arrayAll(source, test)`** — true when every element satisfies the test: `NOT EXISTS (SELECT 1 FROM unnest(<source>) AS pw_el_<n>(v) WHERE NOT (<test>))`. True for an empty array; a null element passes when the test on it is null (SQL three-valued logic).
811
819
 
820
+ **`filter.inList(expr, values, cast?)`** — `<expr> = ANY($1::text[])`: a JS `string[]` / `number[]` bound as one array parameter whatever its length, hashed by Postgres, so it works past `inArray`'s 65,535-parameter limit. Empty list is `false` (no parameter). Strings bind as `text`, integers as `bigint`, other numbers as `numeric`; pass `cast` (`'uuid'`, `'int'`) for other columns: `filter.inList(t.id, ids, 'uuid')`. Mixed types, null, NaN or a bad cast throw.
821
+ **`filter.notInList(expr, values, cast?)`** — `<expr> <> ALL($1::text[])`, same binding and `cast`; empty list is `true`.
822
+
812
823
  `source` is an array column, an `SQL` array expression, a JS `string[]` (bound as one parameter cast `::text[]`, also when empty) or `number[]` (`::numeric[]`), or `filter.jsonbElements(jsonbColumnOrSql)` for a jsonb array (`jsonb_array_elements_text`, elements as text). `test` receives the element as a typed `SQL` (column `text[]` gives `SQL<string>`). Aliases are unique per call, so calls nest.
813
824
 
814
825
  ```ts
@@ -926,6 +937,22 @@ const Unit = pipe.define('units', {
926
937
  await pipe.insert(Unit).values({ tenantId, key }).onConflictDoNothing({ target: ['tenantId', 'keyDigest'] })
927
938
  ```
928
939
 
940
+ A composite foreign key is declared with the `foreignKeys` option. `onDelete` takes an action (`'cascade'`, `'set null'`, ...) that applies to every column of the key, or `{ setNull: [fields] }` / `{ setDefault: [fields] }` to clear only the listed fields (Postgres 15 or later: `ON DELETE SET NULL ("document_id")`). The list must be non-empty, without duplicates, and a subset of `columns`. Changing the action or the list drops and re-adds the constraint.
941
+
942
+ ```ts
943
+ const Cost = pipe.define('costs', {
944
+ id: pipe.field.uuid().primaryKey().defaultRandom(),
945
+ tenantId: pipe.field.uuid(),
946
+ documentId: pipe.field.uuid().nullable(),
947
+ }, {
948
+ foreignKeys: [{
949
+ columns: ['tenantId', 'documentId'],
950
+ references: { definition: Document, columns: ['tenantId', 'id'] },
951
+ onDelete: { setNull: ['documentId'] }, // deleting the document keeps tenantId, nulls documentId
952
+ }],
953
+ })
954
+ ```
955
+
929
956
  ### `pipe.definitions`
930
957
 
931
958
  Returns the global registry of all domain definitions.
@@ -1359,7 +1386,7 @@ Signs a payload with HMAC for outbound delivery — returns body, signature, tim
1359
1386
 
1360
1387
  Mail namespace — email through a job queue, SES v2 or a dev stand-in, bounce and complaint suppression, and receiving.
1361
1388
 
1362
- Opt in with blueprint `email: {}`; the generated migration adds `pipework_email_messages` (`key` unique, `to_address`, `template`, `data`, `status` `queued | sending | sent | suppressed | failed`, `provider_message_id`, `error`, `trace_id`, `created_at`, `sent_at`, `sending_at`, `failed_at`) and `pipework_email_suppressions` (`address` primary key, lowercased; `reason` `bounce | complaint`; `detail`; `created_at`), both `IF NOT EXISTS` and not tenant-scoped. `pipework_email_messages` also has `conversation_id` and `in_reply_to_inbound`. Receiving adds `pipework_email_inbound` (see `mail.inbound`).
1389
+ Opt in with blueprint `email: {}`; the generated migration adds `pipework_email_messages` (`key` unique, `to_address`, `template`, `data`, `status` `queued | sending | sent | suppressed | unsubscribed | failed`, `list`, `provider_message_id`, `error`, `trace_id`, `created_at`, `sent_at`, `sending_at`, `failed_at`) and `pipework_email_suppressions` (`address` primary key, lowercased; `reason` `bounce | complaint`; `detail`; `created_at`), both `IF NOT EXISTS` and not tenant-scoped. `pipework_email_messages` also has `conversation_id` and `in_reply_to_inbound`. Receiving adds `pipework_email_inbound` (see `mail.inbound`).
1363
1390
 
1364
1391
  ### `mail.create`
1365
1392
 
@@ -1369,6 +1396,10 @@ Creates the transactional email interface: `send(db, { to, template, data, key }
1369
1396
 
1370
1397
  `mail.sentTo(db, address, { template? })` (for tests and support tools) reads the messages to an address (any case), oldest first, optionally of one template: `{ id, template, status, data, createdAt, rendered }`, where `rendered` is `{ subject, text, html? }` as delivery would render the stored data (late data resolved; `__late` left out of `data`), or null when the template is gone or throws. `emailLinks.liveFor(db, address, purpose)` (same use) lists the address's links of that purpose that are not used, ended or expired, oldest first: `{ id, token, url, expiresAt }`, the token being the one `check` and `use` take.
1371
1398
 
1399
+ ### Lists and one-click unsubscribe
1400
+
1401
+ Set `unsubscribe: { url, secret }` on `mail.create` (`url` the absolute https URL of the route, http outside production; `secret` at least 32 characters) and send with `list: 'notice-digest'`. The message carries `List-Unsubscribe: <url?t=token>` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058). The token is an HMAC-signed address and list, with no expiry. Mount `mail.unsubscribeHttp()` (public, no tenant) on the HTTP surface: GET shows a confirm page, POST (form-urlencoded, multipart or empty, never read) records the opt-out in `pipework_email_unsubscribes` (`address`, `list`, `created_at`; primary key `(address, list)`; not tenant-scoped; enabled by `email: {}`). A later send on that list to that address ends `unsubscribed` (`Email:unsubscribed` audit record) with no provider call; mail with no list, and other lists, are unaffected. `mail.unsubscribed(db, address, list)` reads the setting; `mail.unsubscribe(...)` and `mail.resubscribe(...)` write it from the app's own settings page (they need a Flow). A template is called `template(data, { unsubscribeUrl })`, with `unsubscribeUrl` the same link, or null without a list.
1402
+
1372
1403
  ### `mail.ses`
1373
1404
 
1374
1405
  Amazon SES v2 provider, SigV4-signed without the AWS SDK.
@@ -1599,6 +1630,35 @@ await github.call({ key: `github:installation:${installationId}`, class: 'bulk'
1599
1630
  await conn.close() // at shutdown
1600
1631
  ```
1601
1632
 
1633
+ ## joinRequests — Join Requests
1634
+
1635
+ A signed-up user asks to join an org that already exists; one of the org's owners approves (the member is added in the same transaction) or refuses. For "your colleague's company already has an account": a domain match, a shared invoice id, an SSO tenant.
1636
+
1637
+ Opt in with blueprint `joinRequests: {}`. The generated migration then includes one tenant-scoped table with forced RLS, `join_request`: `tenant_id` (the org), `user_id`, `status` (`open | approved | refused | withdrawn | expired`), `reason`, `evidence` jsonb, `requested_at`, `expires_at`, `settled_at`, `settled_by`, `role` (set on approve), `note` (set on refuse). A partial unique index allows one `open` row per (org, user).
1638
+
1639
+ ### `joinRequests.create`
1640
+
1641
+ `joinRequests.create({ isOwner, addMember, expiresInMs?, onRequested?, onSettled? })` returns a handle. Roles are the app's, so the app passes them in:
1642
+
1643
+ - `isOwner(tx, { orgId, userId })` — who may settle a request; called inside the org's transaction.
1644
+ - `addMember(tx, { orgId, userId, role, requestId, by })` — adds the member; called inside the approve transaction after the status update, so a throw rolls the approval back.
1645
+ - `expiresInMs` — how long a request stays open. Default 30 days.
1646
+ - `onRequested(tx, request)` / `onSettled(tx, request)` — awaited inside the transaction of the step (settled: approved, refused, withdrawn, expired), so the app can write its notice or enqueue its mail atomically; a throw rolls the step back.
1647
+
1648
+ | method | meaning |
1649
+ |---|---|
1650
+ | `request(orgId, userId, { reason?, evidence? })` | The joiner asks; runs in the named org, so the Flow needs no tenant. Returns `{ request, created }`. While an unexpired open request exists it is returned with `created: false` (no audit, no hook). An open one past its expiry is marked expired first. |
1651
+ | `approve(requestId, { role? }, by)` | Flow tenant. `by` must pass `isOwner`, else `errors.Forbidden`. Returns `{ ok: true, request }` or `{ ok: false, reason: 'not-found' \| 'expired' \| 'already-settled' }`. |
1652
+ | `refuse(requestId, { note? }, by)` | As `approve`, without `addMember`. |
1653
+ | `withdraw(requestId, userId)` | The joiner withdraws their own open request; another user's gets `not-found`. |
1654
+ | `list({ status? })` | Flow tenant's requests, default `open`, newest first. |
1655
+ | `mine(userId)` | One user's requests across orgs, through the system channel, newest first. |
1656
+ | `sweep()` | Marks open requests past expiry as expired (`settled_by` null); schedule it, e.g. a cron job. Returns `{ expired }`. |
1657
+
1658
+ Each step writes a `JoinRequest` audit record (verbs `request`, `approve`, `refuse`, `withdraw`, `expire`) on the org's tenant when the Flow has a trace with an open step. The record carries the request without `evidence`, which may be personal data.
1659
+
1660
+ `mine`, `withdraw` and `sweep` read `join_request` across tenants on the system channel; a database in platform mode (`rls.platformRole`) refuses that read for a tenant table.
1661
+
1602
1662
  ## credits — Prepaid Usage Credits
1603
1663
 
1604
1664
  Prepaid credits per tenant, like CI minutes: a subscription grants an allocation each period, packs are bought on top, and work draws them down. Every grant has its own expiry; spending draws the soonest-expiring grant first. A job reserves before it starts, settles the actual amount when it ends, and releases on failure. Units are whole numbers (safe integers, bigint in SQL); the app decides what one unit is.
@@ -1681,6 +1741,36 @@ export const stripeWebhook = webhook.defineInbound<StripeEvent>(
1681
1741
 
1682
1742
  `sourceRef: event.id` makes Stripe's redelivery of the same event credit once.
1683
1743
 
1744
+ ## creditLine — Postpaid Credit Line
1745
+
1746
+ A postpaid line per tenant, the counterpart of prepaid credits: the work runs first and is paid for later, within limits. A job reserves against the line before it starts, settles its actual cost as an unpaid entry, and releases on failure. Amounts are whole cents (safe integers, bigint in SQL). Who may have a line, the cap amounts and what happens to work past a limit stay the app's.
1747
+
1748
+ Opt in with blueprint `creditLine: {}`. The generated migration then includes six tables, all tenant-scoped with forced RLS: `credit_line` (one per tenant: `ceiling_cents`, `warn_share`, `caps` jsonb, `opened_at`, `closed_at`), `credit_line_reservation` (`job_id` unique per tenant, `amount`, `status`, `settled_amount`, `authorization_id`), `credit_line_entry` (one per settled job: `amount_cents`, `paid_cents`, `authorization_id`, `at`), `credit_line_payment` (`reference` unique per tenant, `amount_cents`, `unapplied_cents`, `at`), `credit_line_payment_application` (which payment cleared how many cents of which entry) and `credit_line_authorization` (`reference` unique per tenant, `amount_cents`, `starts_at`, `ends_at`, `replaces_caps`, `revoked_at`).
1749
+
1750
+ `creditLine.create({ warnShare?, timeZone?, onLineCrossed? })` returns a handle. `warnShare` (default 0.8) is the share of a limit at which it warns; a line or a cap can set its own. `timeZone` (default `'UTC'`) is the zone calendar windows are cut in. Every method takes its tenant from the Flow and has a `...For(tenantId, ...)` form, exactly as credits does; operations on one tenant's line run one after another.
1751
+
1752
+ - `openLine({ ceilingCents, caps?, warnShare? })` — opens the line, or updates an open one (and reopens a closed one). A cap is `{ name, cents, window: 'calendar_year' | 'calendar_month' | { from, to }, warnShare? }`; names are unique. Audited as `CreditLine` `open`.
1753
+ - `closeLine()` — new reserves fail `'no-line'`; held reservations may still settle or release; unpaid work stays.
1754
+ - `reserve({ jobId, amountCents })` — `{ ok: true, reservation }`, `{ ok: false, reason: 'no-line' }` or `{ ok: false, reason: 'limit', limit: { kind, name }, limitCents, usedCents, requestedCents }`. With no authorization in force the ceiling's use is unpaid + held; then each cap's use (settled entries in its window + held) is checked in configured order, and the first one that would be exceeded is the refusal. A `jobId` that already has a reservation returns it unchanged.
1755
+ - `settle({ jobId, amountCents })` — writes the unpaid entry and never fails on limits. The same amount again is a no-op; a different amount, a released job or an unknown job throws. Payments with unapplied cents clear the new entry, oldest payment first.
1756
+ - `release({ jobId })` — `{ released, reservation }`; a settled job throws.
1757
+ - `authorize({ reference, amountCents, from, to, replacesCaps? })` — a purchase order, `{ authorization, created }`, idempotent by reference. While one is in force (`from <= now < to`, not revoked) its amount replaces the ceiling check and the caps named in `replacesCaps`; reservations made then, and their entries, count against it. Dates overlapping an unrevoked authorization throw `ValidationError`. `revokeAuthorization(reference)` ends it; `authorizations()` lists them with `usedCents`, `heldCents`, `remainingCents`, `inForce`.
1758
+ - `recordPayment({ amountCents, reference, at? })` — `{ payment, created }`, idempotent by reference. Clears unpaid entries oldest first and keeps any excess as `unappliedCents`.
1759
+ - `statement({ from, to })` — the entries and the payments (each with the entries it cleared) with `at` in `[from, to)`, and totals `settledCents`, `paidCents` and `unpaidCents` (everything unpaid as of now).
1760
+ - `line()` — `{ open, ceilingCents, unpaidCents, heldCents, availableCents, caps (with usedCents), authorization, ... }`; `availableCents` is the smallest headroom of the limits that apply now.
1761
+
1762
+ `onLineCrossed({ tenantId, limit: { kind: 'ceiling' | 'cap' | 'authorization', name }, usedCents, limitCents, warnAtCents })` is awaited INSIDE the transaction of the write that took a limit's use from below `floor(limit * warnShare)` to at-or-above it (reserve, a settle above its reservation, openLine lowering a limit), in a Flow for that tenant where `pipe()` is that transaction. If it throws, the write rolls back. A release or a payment, or a raised limit, re-arms the warning; nothing is stored.
1763
+
1764
+ Every step is audited on the tenant's chain as entity `CreditLine` with verbs `open`, `close`, `reserve`, `settle`, `release`, `authorize`, `revoke_authorization` and `payment`.
1765
+
1766
+ ```ts
1767
+ const line = pipework.creditLine.create({ onLineCrossed: ({ tenantId, limit }) => warnOwner(tenantId, limit.name) })
1768
+ await line.openLineFor(tenantId, { ceilingCents: 250_000, caps: [{ name: 'yearly', cents: 1_000_000, window: 'calendar_year' }] })
1769
+ const held = await line.reserve({ jobId: run.id, amountCents: 70_000 })
1770
+ if (!held.ok) return queue(run, held)
1771
+ await line.settle({ jobId: run.id, amountCents: used })
1772
+ ```
1773
+
1684
1774
  ## untrustedFetch — Fetch a URL a Stranger Gave
1685
1775
 
1686
1776
  No blueprint key. `untrustedFetch.get(url, options)` fetches one URL with GET and never lets it reach the app's private network. The rules cannot be switched off:
@@ -1948,13 +2038,15 @@ The first three are tenant-scoped with forced row-level security.
1948
2038
 
1949
2039
  ### `support.create`
1950
2040
 
1951
- `support.create({ mail, sinks, queue, canApprove, distinctApprover?, replyTemplate?, screen?, jobType?, maxAttempts?, backoffMs? })` returns a handle.
2041
+ `support.create({ mail, sinks, queue, canApprove, distinctApprover?, replyTemplate?, screen?, resolveContact?, joinThread?, jobType?, maxAttempts?, backoffMs? })` returns a handle.
1952
2042
 
1953
2043
  - `mail` — the app's `mail.create(...)` result, whose `templates` include `...support.templates` (`'pipework.support.reply'`, rendered verbatim: the email is exactly the approved text and html).
1954
2044
  - `sinks` — `SupportSink`s (`Sink<SupportEventPayload>`): `alerts.webhook(...)` and `alerts.memory()` fit where one is expected (`alerts.memory<SupportEventPayload>()` as a variable), or your own `{ name, send(delivery) }`. At least one. A delivery's `kind` is the event kind, `key` the ticket id, `payload` `{ kind, ticketId, tenantId, subject, state, category, at }` (no message bodies).
1955
2045
  - `canApprove({ actorId, tenantId })` — who may approve; e.g. `rbac.can(actorId, 'support:approve', { tenantId })`.
1956
2046
  - `distinctApprover` — default `true`: the author of a draft (last editor) cannot approve it.
1957
2047
  - `screen(email)` — `'accept' | 'separate' | 'drop'`. `drop`: nothing is stored (audited). `separate`: always opens a new ticket (`conversation_id` null), never adds to an existing one, so a forged From address cannot put text into a ticket the agent is reading. Default: virus `FAIL` drops; DMARC `FAIL`, or SPF and DKIM both `FAIL`, separates; anything else, including no verdicts (the folder stand-in), is accepted.
2048
+ - `joinThread` — `'contact'` (default) or `'conversation'`; anything else throws at `create`. With `'contact'`, only the ticket's contact joins it by thread (a colleague's reply opens its own ticket) and `receive` with a `ticketId` refuses a sender who is not the contact. With `'conversation'`, a message threaded into the ticket's conversation, or sent with `ticketId`, joins it and reopens it whoever sent it: the message keeps its own sender in `from_address`, the ticket's contact does not change, a colleague never fills the ticket's null `user_id` (the resolver is not called for one), and the `receive` audit carries `joinedBy: 'conversation'`. A message `screen` separates still never joins. Risk: anyone who holds a reply's Message-ID can add to the ticket, so pair it with a strict `screen` (for example, require a DMARC pass).
2049
+ - `resolveContact({ address, name, tenantId }, tx)` — `Promise<{ userId } | null>`: the user a sender is (`address` lowercased). Called inside the receive's tenant transaction (`tx` is RLS-bound to that tenant; a throw rolls the receive back) by `receiveEmail` and by `receive` without `contact.userId`: for a new ticket (its `user_id` is set in the insert; the `create` audit carries `userId`), and for a message joining a ticket whose `user_id` is null, which it fills (the `receive` audit carries `userIdSet`). A non-null `user_id` is never changed and not resolved again. Not called for a dropped message. Without it, `receiveEmail` tickets have a null `user_id`.
1958
2050
 
1959
2051
  ### Receiving
1960
2052