pipework 0.251.0 → 0.252.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (730) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/config/load.d.ts +2 -0
  3. package/dist/core/config/load.d.ts.map +1 -1
  4. package/dist/core/config/load.js +1 -0
  5. package/dist/core/config/load.js.map +1 -1
  6. package/dist/core/config/namespace.d.ts +1 -0
  7. package/dist/core/config/namespace.d.ts.map +1 -1
  8. package/dist/core/config/schema.d.ts +2 -0
  9. package/dist/core/config/schema.d.ts.map +1 -1
  10. package/dist/core/config/schema.js +1 -0
  11. package/dist/core/config/schema.js.map +1 -1
  12. package/dist/core/surface/types.d.ts +1 -0
  13. package/dist/core/surface/types.d.ts.map +1 -1
  14. package/dist/data/db/namespace.d.ts +3 -0
  15. package/dist/data/db/namespace.d.ts.map +1 -1
  16. package/dist/data/db/namespace.js +3 -0
  17. package/dist/data/db/namespace.js.map +1 -1
  18. package/dist/data/db/pool.d.ts.map +1 -1
  19. package/dist/data/db/pool.js +6 -1
  20. package/dist/data/db/pool.js.map +1 -1
  21. package/dist/data/db/query-label.d.ts +5 -0
  22. package/dist/data/db/query-label.d.ts.map +1 -0
  23. package/dist/data/db/query-label.js +11 -0
  24. package/dist/data/db/query-label.js.map +1 -0
  25. package/dist/data/db/query-watch.d.ts +21 -0
  26. package/dist/data/db/query-watch.d.ts.map +1 -0
  27. package/dist/data/db/query-watch.js +90 -0
  28. package/dist/data/db/query-watch.js.map +1 -0
  29. package/dist/data/query/postgres-js/driver.js +1 -1
  30. package/dist/data/query/postgres-js/driver.js.map +1 -1
  31. package/dist/data/query/postgres-js/session.d.ts +5 -1
  32. package/dist/data/query/postgres-js/session.d.ts.map +1 -1
  33. package/dist/data/query/postgres-js/session.js +16 -11
  34. package/dist/data/query/postgres-js/session.js.map +1 -1
  35. package/dist/data/query/utils.d.ts +2 -0
  36. package/dist/data/query/utils.d.ts.map +1 -1
  37. package/dist/data/query/utils.js.map +1 -1
  38. package/dist/infra/credits/schema.d.ts +1 -1
  39. package/dist/test/plugin.d.ts.map +1 -1
  40. package/dist/test/plugin.js +10 -4
  41. package/dist/test/plugin.js.map +1 -1
  42. package/package.json +4 -3
  43. package/src/async/jobs/abortable-delay.ts +18 -0
  44. package/src/async/jobs/claims.ts +52 -0
  45. package/src/async/jobs/cron.ts +179 -0
  46. package/src/async/jobs/errors.ts +56 -0
  47. package/src/async/jobs/exclusion.ts +117 -0
  48. package/src/async/jobs/execute.ts +219 -0
  49. package/src/async/jobs/index.ts +10 -0
  50. package/src/async/jobs/listener.ts +135 -0
  51. package/src/async/jobs/namespace.ts +20 -0
  52. package/src/async/jobs/queue-door.ts +44 -0
  53. package/src/async/jobs/queue.ts +1012 -0
  54. package/src/async/jobs/run-script.ts +67 -0
  55. package/src/async/jobs/scheduler.ts +218 -0
  56. package/src/async/jobs/schedules-table.ts +37 -0
  57. package/src/async/jobs/serialize-error.ts +48 -0
  58. package/src/async/jobs/table.ts +191 -0
  59. package/src/async/pipeline/define.ts +53 -0
  60. package/src/async/pipeline/errors.ts +35 -0
  61. package/src/async/pipeline/execute.ts +53 -0
  62. package/src/async/pipeline/index.ts +13 -0
  63. package/src/async/pipeline/namespace.ts +12 -0
  64. package/src/async/pipeline/resume.ts +67 -0
  65. package/src/async/pipeline/run-steps.ts +98 -0
  66. package/src/async/pipeline/types.ts +57 -0
  67. package/src/async/state-machine/define.ts +64 -0
  68. package/src/async/state-machine/errors.ts +29 -0
  69. package/src/async/state-machine/index.ts +3 -0
  70. package/src/async/state-machine/namespace.ts +12 -0
  71. package/src/async/state-machine/transition.ts +90 -0
  72. package/src/async/state-machine/types.ts +37 -0
  73. package/src/async/steps/errors.ts +31 -0
  74. package/src/async/steps/graph.ts +1214 -0
  75. package/src/async/steps/hash.ts +40 -0
  76. package/src/async/steps/index.ts +7 -0
  77. package/src/async/steps/namespace.ts +7 -0
  78. package/src/async/steps/table.ts +97 -0
  79. package/src/async/steps/types.ts +318 -0
  80. package/src/audit/chain/index.ts +9 -0
  81. package/src/audit/chain/install.ts +113 -0
  82. package/src/audit/chain/retire.ts +52 -0
  83. package/src/audit/chain/verify.ts +257 -0
  84. package/src/audit/query/cursor.ts +37 -0
  85. package/src/audit/query/index.ts +9 -0
  86. package/src/audit/query/query.ts +223 -0
  87. package/src/audit/schema/audit-record-fields.ts +68 -0
  88. package/src/audit/schema/audit-record.ts +23 -0
  89. package/src/auth/auth/audit.ts +31 -0
  90. package/src/auth/auth/chain.ts +84 -0
  91. package/src/auth/auth/cookie.ts +136 -0
  92. package/src/auth/auth/email-links.ts +311 -0
  93. package/src/auth/auth/guard.ts +128 -0
  94. package/src/auth/auth/index.ts +18 -0
  95. package/src/auth/auth/multi-org.ts +337 -0
  96. package/src/auth/auth/namespace.ts +34 -0
  97. package/src/auth/auth/parse-expiry.ts +13 -0
  98. package/src/auth/auth/passwords.ts +110 -0
  99. package/src/auth/auth/schema.ts +110 -0
  100. package/src/auth/auth/sessions.ts +565 -0
  101. package/src/auth/auth/sign-in.ts +156 -0
  102. package/src/auth/auth/signing-keys.ts +69 -0
  103. package/src/auth/auth/types.ts +26 -0
  104. package/src/auth/auth/uuid.ts +10 -0
  105. package/src/auth/rbac/cache.ts +55 -0
  106. package/src/auth/rbac/check.ts +30 -0
  107. package/src/auth/rbac/create.ts +35 -0
  108. package/src/auth/rbac/index.ts +5 -0
  109. package/src/auth/rbac/middleware.ts +20 -0
  110. package/src/auth/rbac/namespace.ts +15 -0
  111. package/src/auth/rbac/resolve.ts +31 -0
  112. package/src/auth/rbac/schema.sql +31 -0
  113. package/src/auth/rbac/seed.ts +67 -0
  114. package/src/auth/rbac/tables.ts +32 -0
  115. package/src/auth/rbac/types.ts +65 -0
  116. package/src/auth/tenant/extract.ts +33 -0
  117. package/src/auth/tenant/in-tenant.ts +49 -0
  118. package/src/auth/tenant/index.ts +7 -0
  119. package/src/auth/tenant/namespace.ts +23 -0
  120. package/src/auth/tenant/propagate.ts +20 -0
  121. package/src/auth/tenant/rls.ts +120 -0
  122. package/src/auth/tenant/scope.ts +33 -0
  123. package/src/auth/tenant/scoped-db.ts +581 -0
  124. package/src/auth/tenant/types.ts +14 -0
  125. package/src/auth/tenant/validate.ts +27 -0
  126. package/src/cli/commands/add.ts +158 -0
  127. package/src/cli/commands/alerts.ts +98 -0
  128. package/src/cli/commands/check.ts +1418 -0
  129. package/src/cli/commands/codemod.ts +78 -0
  130. package/src/cli/commands/db.ts +356 -0
  131. package/src/cli/commands/dev.ts +101 -0
  132. package/src/cli/commands/generate.ts +75 -0
  133. package/src/cli/commands/init.ts +36 -0
  134. package/src/cli/commands/install.ts +28 -0
  135. package/src/cli/commands/lint.ts +62 -0
  136. package/src/cli/commands/migrate.ts +132 -0
  137. package/src/cli/commands/openapi.ts +63 -0
  138. package/src/cli/commands/remove.ts +72 -0
  139. package/src/cli/commands/run.ts +50 -0
  140. package/src/cli/commands/scope.ts +54 -0
  141. package/src/cli/commands/serve.ts +68 -0
  142. package/src/cli/commands/shard.ts +31 -0
  143. package/src/cli/commands/shared/config-pipework-version.ts +28 -0
  144. package/src/cli/commands/shared/find-config.ts +22 -0
  145. package/src/cli/commands/shared/load-config.ts +35 -0
  146. package/src/cli/commands/shared/pnpm-install.ts +95 -0
  147. package/src/cli/commands/shared/report-removed.ts +8 -0
  148. package/src/cli/commands/sync.ts +32 -0
  149. package/src/cli/commands/test.ts +307 -0
  150. package/src/cli/commands/upgrade.ts +48 -0
  151. package/src/cli/dev-bootstrap.ts +24 -0
  152. package/src/cli/errors.ts +10 -0
  153. package/src/cli/index.ts +303 -0
  154. package/src/cli/output.ts +17 -0
  155. package/src/cli/port.ts +15 -0
  156. package/src/cli/surface-kinds.ts +20 -0
  157. package/src/client/index.ts +4 -0
  158. package/src/client/poll.ts +130 -0
  159. package/src/client/report-fault.ts +119 -0
  160. package/src/core/config/app-pool.ts +19 -0
  161. package/src/core/config/aws-env.ts +29 -0
  162. package/src/core/config/base-dir.ts +24 -0
  163. package/src/core/config/discover.ts +64 -0
  164. package/src/core/config/env-resolve.ts +87 -0
  165. package/src/core/config/env-types.ts +116 -0
  166. package/src/core/config/env.ts +55 -0
  167. package/src/core/config/errors.ts +9 -0
  168. package/src/core/config/import-config.ts +40 -0
  169. package/src/core/config/index.ts +8 -0
  170. package/src/core/config/load.ts +417 -0
  171. package/src/core/config/namespace.ts +14 -0
  172. package/src/core/config/resolve.ts +195 -0
  173. package/src/core/config/schema.ts +488 -0
  174. package/src/core/config/statement-observer.ts +48 -0
  175. package/src/core/config/ts-inline-hooks.ts +75 -0
  176. package/src/core/config/ts-register.ts +4 -0
  177. package/src/core/config/ts-resolve-hooks.ts +101 -0
  178. package/src/core/errors/base.ts +49 -0
  179. package/src/core/errors/http.ts +146 -0
  180. package/src/core/errors/index.ts +2 -0
  181. package/src/core/errors/namespace.ts +31 -0
  182. package/src/core/lifecycle/drain.ts +33 -0
  183. package/src/core/lifecycle/health.ts +72 -0
  184. package/src/core/lifecycle/index.ts +4 -0
  185. package/src/core/lifecycle/namespace.ts +7 -0
  186. package/src/core/lifecycle/startup.ts +102 -0
  187. package/src/core/lifecycle/types.ts +36 -0
  188. package/src/core/logging/context.ts +60 -0
  189. package/src/core/logging/error-chain.ts +68 -0
  190. package/src/core/logging/index.ts +5 -0
  191. package/src/core/logging/logger.ts +65 -0
  192. package/src/core/logging/namespace.ts +16 -0
  193. package/src/core/logging/proxy.ts +22 -0
  194. package/src/core/logging/redact-match.ts +24 -0
  195. package/src/core/logging/types.ts +47 -0
  196. package/src/core/pipework.ts +566 -0
  197. package/src/core/runtime/broker.ts +69 -0
  198. package/src/core/surface/http.ts +133 -0
  199. package/src/core/surface/index.ts +11 -0
  200. package/src/core/surface/job-timeout.ts +40 -0
  201. package/src/core/surface/namespace.ts +13 -0
  202. package/src/core/surface/script.ts +33 -0
  203. package/src/core/surface/start.ts +142 -0
  204. package/src/core/surface/types.ts +225 -0
  205. package/src/core/surface/validate.ts +88 -0
  206. package/src/core/surface/worker.ts +528 -0
  207. package/src/core/validation/connectivity.ts +49 -0
  208. package/src/core/validation/domain.ts +99 -0
  209. package/src/core/validation/index.ts +5 -0
  210. package/src/core/validation/queue-tables.ts +312 -0
  211. package/src/core/validation/rls-role.ts +138 -0
  212. package/src/core/validation/startup.ts +51 -0
  213. package/src/core/validation/surfaces.ts +65 -0
  214. package/src/core/validation/types.ts +25 -0
  215. package/src/data/db/advisory-lock.ts +55 -0
  216. package/src/data/db/aggregates.ts +172 -0
  217. package/src/data/db/array-elements.ts +111 -0
  218. package/src/data/db/bulk-set.ts +53 -0
  219. package/src/data/db/col/index.ts +14 -0
  220. package/src/data/db/composed.ts +53 -0
  221. package/src/data/db/db.ts +276 -0
  222. package/src/data/db/excluded.ts +16 -0
  223. package/src/data/db/expressions.ts +473 -0
  224. package/src/data/db/filter.ts +90 -0
  225. package/src/data/db/function-source.ts +114 -0
  226. package/src/data/db/identifiers.ts +16 -0
  227. package/src/data/db/idx/index.ts +9 -0
  228. package/src/data/db/in-list.ts +49 -0
  229. package/src/data/db/index.ts +9 -0
  230. package/src/data/db/insert-split.ts +137 -0
  231. package/src/data/db/instrumentation.ts +82 -0
  232. package/src/data/db/maintenance.ts +28 -0
  233. package/src/data/db/namespace.ts +107 -0
  234. package/src/data/db/owner-labels.ts +9 -0
  235. package/src/data/db/pool.ts +187 -0
  236. package/src/data/db/query-label.ts +13 -0
  237. package/src/data/db/query-watch.ts +116 -0
  238. package/src/data/db/raw-client-warning.ts +37 -0
  239. package/src/data/db/readonly.ts +23 -0
  240. package/src/data/db/reservation.ts +151 -0
  241. package/src/data/db/schema-features.ts +14 -0
  242. package/src/data/db/serializable.ts +83 -0
  243. package/src/data/db/sql.ts +1 -0
  244. package/src/data/db/timeouts.ts +76 -0
  245. package/src/data/db/types.ts +18 -0
  246. package/src/data/db/unbound.ts +33 -0
  247. package/src/data/db/values.ts +29 -0
  248. package/src/data/domain/brand.ts +15 -0
  249. package/src/data/domain/build-factory.ts +193 -0
  250. package/src/data/domain/build-schema.ts +161 -0
  251. package/src/data/domain/build-table.ts +355 -0
  252. package/src/data/domain/define.ts +239 -0
  253. package/src/data/domain/external-registry.ts +34 -0
  254. package/src/data/domain/external.ts +77 -0
  255. package/src/data/domain/field.ts +279 -0
  256. package/src/data/domain/index.ts +13 -0
  257. package/src/data/domain/naming.ts +6 -0
  258. package/src/data/domain/projections.ts +57 -0
  259. package/src/data/domain/trace-safe-projection.ts +31 -0
  260. package/src/data/domain/traits.ts +108 -0
  261. package/src/data/domain/types.ts +289 -0
  262. package/src/data/jsonb/composed.ts +18 -0
  263. package/src/data/jsonb/index.ts +1 -0
  264. package/src/data/jsonb/namespace.ts +30 -0
  265. package/src/data/jsonb/query.ts +86 -0
  266. package/src/data/migrate/append-only-generate.ts +47 -0
  267. package/src/data/migrate/applied.ts +47 -0
  268. package/src/data/migrate/apply.ts +309 -0
  269. package/src/data/migrate/check.ts +119 -0
  270. package/src/data/migrate/diff.ts +357 -0
  271. package/src/data/migrate/door-generate.ts +130 -0
  272. package/src/data/migrate/framework-rls.ts +58 -0
  273. package/src/data/migrate/generate.ts +201 -0
  274. package/src/data/migrate/history.ts +215 -0
  275. package/src/data/migrate/index.ts +9 -0
  276. package/src/data/migrate/internal-definitions.ts +175 -0
  277. package/src/data/migrate/post-process.ts +73 -0
  278. package/src/data/migrate/pragma.ts +73 -0
  279. package/src/data/migrate/provision-framework.ts +147 -0
  280. package/src/data/migrate/rls-generate.ts +61 -0
  281. package/src/data/migrate/run.ts +124 -0
  282. package/src/data/migrate/safety.ts +167 -0
  283. package/src/data/migrate/snapshot.ts +368 -0
  284. package/src/data/migrate/sql-emit.ts +392 -0
  285. package/src/data/migrate/statements.ts +312 -0
  286. package/src/data/query/alias.ts +126 -0
  287. package/src/data/query/batch.ts +8 -0
  288. package/src/data/query/cache/core/cache.ts +78 -0
  289. package/src/data/query/cache/core/index.ts +1 -0
  290. package/src/data/query/cache/core/types.ts +29 -0
  291. package/src/data/query/cache/readme.md +230 -0
  292. package/src/data/query/casing.ts +76 -0
  293. package/src/data/query/collation.ts +11 -0
  294. package/src/data/query/column-builder.ts +370 -0
  295. package/src/data/query/column.ts +150 -0
  296. package/src/data/query/entity.ts +42 -0
  297. package/src/data/query/errors.ts +33 -0
  298. package/src/data/query/index.ts +14 -0
  299. package/src/data/query/logger.ts +47 -0
  300. package/src/data/query/operations.ts +45 -0
  301. package/src/data/query/pg-core/alias.ts +12 -0
  302. package/src/data/query/pg-core/checks.ts +32 -0
  303. package/src/data/query/pg-core/columns/all.ts +71 -0
  304. package/src/data/query/pg-core/columns/bigint.ts +110 -0
  305. package/src/data/query/pg-core/columns/bigserial.ts +128 -0
  306. package/src/data/query/pg-core/columns/boolean.ts +43 -0
  307. package/src/data/query/pg-core/columns/char.ts +85 -0
  308. package/src/data/query/pg-core/columns/cidr.ts +43 -0
  309. package/src/data/query/pg-core/columns/common.ts +366 -0
  310. package/src/data/query/pg-core/columns/custom.ts +232 -0
  311. package/src/data/query/pg-core/columns/date.common.ts +15 -0
  312. package/src/data/query/pg-core/columns/date.ts +112 -0
  313. package/src/data/query/pg-core/columns/double-precision.ts +55 -0
  314. package/src/data/query/pg-core/columns/enum.ts +201 -0
  315. package/src/data/query/pg-core/columns/index.ts +35 -0
  316. package/src/data/query/pg-core/columns/inet.ts +43 -0
  317. package/src/data/query/pg-core/columns/int.common.ts +57 -0
  318. package/src/data/query/pg-core/columns/integer.ts +53 -0
  319. package/src/data/query/pg-core/columns/interval.ts +83 -0
  320. package/src/data/query/pg-core/columns/json.ts +58 -0
  321. package/src/data/query/pg-core/columns/jsonb.ts +56 -0
  322. package/src/data/query/pg-core/columns/line.ts +117 -0
  323. package/src/data/query/pg-core/columns/macaddr.ts +43 -0
  324. package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
  325. package/src/data/query/pg-core/columns/numeric.ts +227 -0
  326. package/src/data/query/pg-core/columns/point.ts +127 -0
  327. package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
  328. package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
  329. package/src/data/query/pg-core/columns/real.ts +58 -0
  330. package/src/data/query/pg-core/columns/serial.ts +55 -0
  331. package/src/data/query/pg-core/columns/smallint.ts +53 -0
  332. package/src/data/query/pg-core/columns/smallserial.ts +60 -0
  333. package/src/data/query/pg-core/columns/text.ts +67 -0
  334. package/src/data/query/pg-core/columns/time.ts +72 -0
  335. package/src/data/query/pg-core/columns/timestamp.ts +163 -0
  336. package/src/data/query/pg-core/columns/uuid.ts +51 -0
  337. package/src/data/query/pg-core/columns/varchar.ts +89 -0
  338. package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
  339. package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
  340. package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
  341. package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
  342. package/src/data/query/pg-core/db.ts +674 -0
  343. package/src/data/query/pg-core/dialect.ts +1404 -0
  344. package/src/data/query/pg-core/expressions.ts +25 -0
  345. package/src/data/query/pg-core/foreign-keys.ts +130 -0
  346. package/src/data/query/pg-core/index.ts +21 -0
  347. package/src/data/query/pg-core/indexes.ts +276 -0
  348. package/src/data/query/pg-core/policies.ts +56 -0
  349. package/src/data/query/pg-core/primary-keys.ts +62 -0
  350. package/src/data/query/pg-core/query-builders/count.ts +86 -0
  351. package/src/data/query/pg-core/query-builders/delete.ts +293 -0
  352. package/src/data/query/pg-core/query-builders/index.ts +7 -0
  353. package/src/data/query/pg-core/query-builders/insert.ts +452 -0
  354. package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
  355. package/src/data/query/pg-core/query-builders/query.ts +157 -0
  356. package/src/data/query/pg-core/query-builders/raw.ts +49 -0
  357. package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
  358. package/src/data/query/pg-core/query-builders/select.ts +1346 -0
  359. package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
  360. package/src/data/query/pg-core/query-builders/update.ts +626 -0
  361. package/src/data/query/pg-core/roles.ts +41 -0
  362. package/src/data/query/pg-core/schema.ts +72 -0
  363. package/src/data/query/pg-core/sequence.ts +41 -0
  364. package/src/data/query/pg-core/session.ts +292 -0
  365. package/src/data/query/pg-core/subquery.ts +46 -0
  366. package/src/data/query/pg-core/table.ts +252 -0
  367. package/src/data/query/pg-core/unique-constraint.ts +73 -0
  368. package/src/data/query/pg-core/utils/array.ts +95 -0
  369. package/src/data/query/pg-core/utils/index.ts +1 -0
  370. package/src/data/query/pg-core/utils.ts +104 -0
  371. package/src/data/query/pg-core/view-base.ts +14 -0
  372. package/src/data/query/pg-core/view-common.ts +1 -0
  373. package/src/data/query/pg-core/view.ts +435 -0
  374. package/src/data/query/pg-core/with-builder.ts +41 -0
  375. package/src/data/query/postgres-js/README.md +46 -0
  376. package/src/data/query/postgres-js/array-parser.ts +45 -0
  377. package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
  378. package/src/data/query/postgres-js/driver.ts +163 -0
  379. package/src/data/query/postgres-js/index.ts +2 -0
  380. package/src/data/query/postgres-js/session.ts +234 -0
  381. package/src/data/query/primary-key.ts +11 -0
  382. package/src/data/query/query-builders/query-builder.ts +19 -0
  383. package/src/data/query/query-builders/select.types.ts +175 -0
  384. package/src/data/query/query-promise.ts +35 -0
  385. package/src/data/query/relations.ts +725 -0
  386. package/src/data/query/runnable-query.ts +12 -0
  387. package/src/data/query/selection-proxy.ts +121 -0
  388. package/src/data/query/session.ts +8 -0
  389. package/src/data/query/sql/expressions/conditions.ts +741 -0
  390. package/src/data/query/sql/expressions/index.ts +2 -0
  391. package/src/data/query/sql/expressions/select.ts +45 -0
  392. package/src/data/query/sql/functions/aggregate.ts +129 -0
  393. package/src/data/query/sql/functions/index.ts +2 -0
  394. package/src/data/query/sql/functions/vector.ts +178 -0
  395. package/src/data/query/sql/index.ts +3 -0
  396. package/src/data/query/sql/sql.ts +794 -0
  397. package/src/data/query/subquery.ts +53 -0
  398. package/src/data/query/table.ts +208 -0
  399. package/src/data/query/table.utils.ts +2 -0
  400. package/src/data/query/tracing-utils.ts +3 -0
  401. package/src/data/query/tracing.ts +11 -0
  402. package/src/data/query/utils.ts +357 -0
  403. package/src/data/query/version.ts +4 -0
  404. package/src/data/query/view-common.ts +1 -0
  405. package/src/data/schema/branded.ts +13 -0
  406. package/src/data/schema/generated/coerce.ts +5 -0
  407. package/src/data/schema/generated/combinator.ts +6 -0
  408. package/src/data/schema/generated/encoding.ts +5 -0
  409. package/src/data/schema/generated/error.ts +6 -0
  410. package/src/data/schema/generated/format.ts +5 -0
  411. package/src/data/schema/generated/type-constructor.ts +10 -0
  412. package/src/data/schema/generated/type.ts +6 -0
  413. package/src/data/schema/index.ts +6 -0
  414. package/src/data/schema/json-schema.ts +33 -0
  415. package/src/data/schema/namespace.ts +55 -0
  416. package/src/data/schema/parse.ts +39 -0
  417. package/src/data/schema/types.ts +39 -0
  418. package/src/data/temporal/append-only.ts +134 -0
  419. package/src/data/temporal/close.ts +27 -0
  420. package/src/data/temporal/columns.ts +10 -0
  421. package/src/data/temporal/definition-queries.ts +70 -0
  422. package/src/data/temporal/errors.ts +10 -0
  423. package/src/data/temporal/get-current.ts +22 -0
  424. package/src/data/temporal/index.ts +9 -0
  425. package/src/data/temporal/namespace.ts +36 -0
  426. package/src/data/temporal/query.ts +25 -0
  427. package/src/data/temporal/resolve.ts +17 -0
  428. package/src/data/temporal/revise.ts +67 -0
  429. package/src/data/temporal/types.ts +22 -0
  430. package/src/data/valve/artifact.ts +33 -0
  431. package/src/data/valve/contract-diff.ts +21 -0
  432. package/src/data/valve/cross.ts +57 -0
  433. package/src/data/valve/crossing-ledger.ts +50 -0
  434. package/src/data/valve/decision.ts +44 -0
  435. package/src/data/valve/durable-crossing.ts +52 -0
  436. package/src/data/valve/execute-crossing.ts +77 -0
  437. package/src/data/valve/faucet.ts +56 -0
  438. package/src/data/valve/gate.ts +114 -0
  439. package/src/data/valve/graph.ts +77 -0
  440. package/src/data/valve/index.ts +53 -0
  441. package/src/data/valve/namespace.ts +57 -0
  442. package/src/data/valve/registry.ts +50 -0
  443. package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
  444. package/src/data/valve/schema/valve-crossing.ts +9 -0
  445. package/src/data/valve/supply.ts +154 -0
  446. package/src/data/valve/types.ts +143 -0
  447. package/src/data/vector/columns.ts +27 -0
  448. package/src/data/vector/index.ts +4 -0
  449. package/src/data/vector/namespace.ts +33 -0
  450. package/src/data/vector/query.ts +85 -0
  451. package/src/data/vector/validate.ts +16 -0
  452. package/src/data/write/audit/actor.ts +15 -0
  453. package/src/data/write/audit/audit-columns.ts +18 -0
  454. package/src/data/write/audit/audit-select.ts +111 -0
  455. package/src/data/write/audit/bound-transaction.ts +14 -0
  456. package/src/data/write/audit/cte-compile.ts +32 -0
  457. package/src/data/write/audit/decode-returning.ts +47 -0
  458. package/src/data/write/audit/final-select.ts +43 -0
  459. package/src/data/write/audit/infra-audit.ts +36 -0
  460. package/src/data/write/audit/primary-key.ts +65 -0
  461. package/src/data/write/audit/projection.ts +41 -0
  462. package/src/data/write/audit/read-touches.ts +86 -0
  463. package/src/data/write/audit/touch-match.ts +78 -0
  464. package/src/data/write/audit/verb-delete.ts +62 -0
  465. package/src/data/write/audit/verb-insert.ts +36 -0
  466. package/src/data/write/audit/verb-select.ts +47 -0
  467. package/src/data/write/audit/verb-update.ts +60 -0
  468. package/src/data/write/audit/verb-upsert.ts +104 -0
  469. package/src/data/write/conflict.ts +43 -0
  470. package/src/data/write/delete.ts +38 -0
  471. package/src/data/write/execute.ts +243 -0
  472. package/src/data/write/index.ts +11 -0
  473. package/src/data/write/insert.ts +63 -0
  474. package/src/data/write/state.ts +43 -0
  475. package/src/data/write/update.ts +54 -0
  476. package/src/data/write/upsert.ts +58 -0
  477. package/src/data/write/values.ts +19 -0
  478. package/src/index.ts +230 -0
  479. package/src/infra/access-links/code.ts +70 -0
  480. package/src/infra/access-links/handle.ts +892 -0
  481. package/src/infra/access-links/index.ts +9 -0
  482. package/src/infra/access-links/namespace.ts +30 -0
  483. package/src/infra/access-links/person-check.ts +177 -0
  484. package/src/infra/access-links/schema.ts +71 -0
  485. package/src/infra/alerts/async-sources.ts +231 -0
  486. package/src/infra/alerts/audit.ts +36 -0
  487. package/src/infra/alerts/beat.ts +123 -0
  488. package/src/infra/alerts/client-faults.ts +111 -0
  489. package/src/infra/alerts/faults.ts +156 -0
  490. package/src/infra/alerts/handle.ts +778 -0
  491. package/src/infra/alerts/http.ts +192 -0
  492. package/src/infra/alerts/index.ts +12 -0
  493. package/src/infra/alerts/infra-sources.ts +180 -0
  494. package/src/infra/alerts/namespace.ts +81 -0
  495. package/src/infra/alerts/redact.ts +21 -0
  496. package/src/infra/alerts/request-faults.ts +41 -0
  497. package/src/infra/alerts/schema.ts +124 -0
  498. package/src/infra/alerts/sinks.ts +125 -0
  499. package/src/infra/alerts/source-options.ts +26 -0
  500. package/src/infra/alerts/sources.ts +503 -0
  501. package/src/infra/alerts/subsystem-sources.ts +137 -0
  502. package/src/infra/audit/emit.ts +87 -0
  503. package/src/infra/audit/index.ts +2 -0
  504. package/src/infra/audit/namespace.ts +22 -0
  505. package/src/infra/audit/schema.sql +35 -0
  506. package/src/infra/audit/types.ts +18 -0
  507. package/src/infra/aws/credentials.ts +147 -0
  508. package/src/infra/aws/sigv4.ts +140 -0
  509. package/src/infra/cache/create.ts +58 -0
  510. package/src/infra/cache/entry.ts +81 -0
  511. package/src/infra/cache/index.ts +4 -0
  512. package/src/infra/cache/namespace.ts +10 -0
  513. package/src/infra/cache/tenant.ts +93 -0
  514. package/src/infra/cache/types.ts +24 -0
  515. package/src/infra/crawl/audit.ts +35 -0
  516. package/src/infra/crawl/errors.ts +25 -0
  517. package/src/infra/crawl/handle.ts +693 -0
  518. package/src/infra/crawl/index.ts +9 -0
  519. package/src/infra/crawl/links.ts +160 -0
  520. package/src/infra/crawl/namespace.ts +36 -0
  521. package/src/infra/crawl/robots.ts +100 -0
  522. package/src/infra/crawl/schema.ts +89 -0
  523. package/src/infra/credit-line/handle.ts +883 -0
  524. package/src/infra/credit-line/index.ts +6 -0
  525. package/src/infra/credit-line/namespace.ts +7 -0
  526. package/src/infra/credit-line/schema.ts +96 -0
  527. package/src/infra/credits/handle.ts +706 -0
  528. package/src/infra/credits/index.ts +5 -0
  529. package/src/infra/credits/namespace.ts +7 -0
  530. package/src/infra/credits/schema.ts +83 -0
  531. package/src/infra/email/audit.ts +36 -0
  532. package/src/infra/email/inbound.ts +622 -0
  533. package/src/infra/email/index.ts +11 -0
  534. package/src/infra/email/mail.ts +517 -0
  535. package/src/infra/email/mime.ts +447 -0
  536. package/src/infra/email/namespace.ts +22 -0
  537. package/src/infra/email/providers.ts +111 -0
  538. package/src/infra/email/schema.ts +96 -0
  539. package/src/infra/email/sns.ts +200 -0
  540. package/src/infra/email/unsubscribe.ts +44 -0
  541. package/src/infra/fetch/address.ts +88 -0
  542. package/src/infra/fetch/connect-retry.ts +87 -0
  543. package/src/infra/fetch/handle.ts +329 -0
  544. package/src/infra/fetch/index.ts +7 -0
  545. package/src/infra/fetch/namespace.ts +70 -0
  546. package/src/infra/idempotency/index.ts +3 -0
  547. package/src/infra/idempotency/middleware.ts +87 -0
  548. package/src/infra/idempotency/store.ts +92 -0
  549. package/src/infra/idempotency/types.ts +30 -0
  550. package/src/infra/join-requests/handle.ts +312 -0
  551. package/src/infra/join-requests/index.ts +2 -0
  552. package/src/infra/join-requests/namespace.ts +7 -0
  553. package/src/infra/join-requests/schema.ts +33 -0
  554. package/src/infra/outbox/core.ts +165 -0
  555. package/src/infra/quota/errors.ts +32 -0
  556. package/src/infra/quota/handle.ts +215 -0
  557. package/src/infra/quota/index.ts +5 -0
  558. package/src/infra/quota/namespace.ts +7 -0
  559. package/src/infra/quota/schema.ts +15 -0
  560. package/src/infra/storage/backend.ts +34 -0
  561. package/src/infra/storage/handle.ts +513 -0
  562. package/src/infra/storage/index.ts +9 -0
  563. package/src/infra/storage/local.ts +104 -0
  564. package/src/infra/storage/namespace.ts +13 -0
  565. package/src/infra/storage/s3.ts +257 -0
  566. package/src/infra/storage/schema.ts +24 -0
  567. package/src/infra/support/handle.ts +854 -0
  568. package/src/infra/support/http.ts +229 -0
  569. package/src/infra/support/index.ts +7 -0
  570. package/src/infra/support/namespace.ts +12 -0
  571. package/src/infra/support/schema.ts +96 -0
  572. package/src/infra/webhook/inbound.ts +45 -0
  573. package/src/infra/webhook/index.ts +5 -0
  574. package/src/infra/webhook/namespace.ts +24 -0
  575. package/src/infra/webhook/outbound.ts +315 -0
  576. package/src/infra/webhook/sign.ts +32 -0
  577. package/src/infra/webhook/verify.ts +82 -0
  578. package/src/invariants/assert.ts +26 -0
  579. package/src/invariants/index.ts +1 -0
  580. package/src/lint/config.ts +56 -0
  581. package/src/lint/doctrine.ts +163 -0
  582. package/src/lint/include.ts +97 -0
  583. package/src/lint/index.ts +3 -0
  584. package/src/lint/preset.ts +10 -0
  585. package/src/lint/resolver.ts +218 -0
  586. package/src/lint/rules/no-reexports.ts +46 -0
  587. package/src/request/behavior/audited.ts +85 -0
  588. package/src/request/behavior/cached.ts +62 -0
  589. package/src/request/behavior/compose.ts +28 -0
  590. package/src/request/behavior/index.ts +5 -0
  591. package/src/request/behavior/namespace.ts +16 -0
  592. package/src/request/behavior/types.ts +26 -0
  593. package/src/request/behavior/versioned.ts +54 -0
  594. package/src/request/context/create.ts +103 -0
  595. package/src/request/context/guard.ts +49 -0
  596. package/src/request/context/index.ts +4 -0
  597. package/src/request/context/run-in-transaction.ts +200 -0
  598. package/src/request/context/store.ts +28 -0
  599. package/src/request/context/types.ts +50 -0
  600. package/src/request/di/builder.ts +535 -0
  601. package/src/request/di/index.ts +8 -0
  602. package/src/request/di/resolve.ts +271 -0
  603. package/src/request/di/types.ts +262 -0
  604. package/src/request/fitting/namespace.ts +7 -0
  605. package/src/request/fixture/batch.ts +71 -0
  606. package/src/request/fixture/builder.ts +26 -0
  607. package/src/request/fixture/errors.ts +10 -0
  608. package/src/request/fixture/index.ts +8 -0
  609. package/src/request/fixture/namespace.ts +23 -0
  610. package/src/request/fixture/paginate.ts +44 -0
  611. package/src/request/fixture/to-handlers.ts +260 -0
  612. package/src/request/fixture/types.ts +79 -0
  613. package/src/request/flow/act-as.ts +78 -0
  614. package/src/request/flow/namespace.ts +17 -0
  615. package/src/request/http/error-step.ts +42 -0
  616. package/src/request/http/file.ts +141 -0
  617. package/src/request/http/index.ts +4 -0
  618. package/src/request/http/middleware.ts +141 -0
  619. package/src/request/http/namespace.ts +5 -0
  620. package/src/request/http/rate-limit-classes.ts +170 -0
  621. package/src/request/http/response.ts +82 -0
  622. package/src/request/http/server.ts +916 -0
  623. package/src/request/http/sniff.ts +261 -0
  624. package/src/request/http/types.ts +250 -0
  625. package/src/request/http/upload.ts +284 -0
  626. package/src/request/internal/text.ts +6 -0
  627. package/src/request/openapi/generate.ts +205 -0
  628. package/src/request/openapi/index.ts +2 -0
  629. package/src/request/openapi/namespace.ts +7 -0
  630. package/src/test/auth.ts +13 -0
  631. package/src/test/auto-setup.ts +45 -0
  632. package/src/test/context.ts +91 -0
  633. package/src/test/crawl-fixture.ts +157 -0
  634. package/src/test/discover-fork.ts +17 -0
  635. package/src/test/fingerprint.ts +135 -0
  636. package/src/test/globals.d.ts +7 -0
  637. package/src/test/index.ts +19 -0
  638. package/src/test/namespace.ts +87 -0
  639. package/src/test/plugin.ts +189 -0
  640. package/src/test/progress.ts +185 -0
  641. package/src/test/reap-after-run.ts +28 -0
  642. package/src/test/reap.ts +286 -0
  643. package/src/test/reset.ts +253 -0
  644. package/src/test/savepoints.ts +172 -0
  645. package/src/test/setup.ts +1422 -0
  646. package/src/test/system-channel.ts +50 -0
  647. package/src/test/vitest-plugin.ts +142 -0
  648. package/src/test/vitest-workspace.ts +507 -0
  649. package/src/test/vitest.ts +333 -0
  650. package/src/test/watch.ts +105 -0
  651. package/src/trace/capture/bounded-serializer.ts +146 -0
  652. package/src/trace/cardinality/analyze.ts +173 -0
  653. package/src/trace/cardinality/branch-context.ts +214 -0
  654. package/src/trace/cardinality/check.ts +33 -0
  655. package/src/trace/cardinality/classify-edge.ts +64 -0
  656. package/src/trace/cardinality/structural-max.ts +107 -0
  657. package/src/trace/check/coverage-check.ts +188 -0
  658. package/src/trace/check/flow-check.ts +103 -0
  659. package/src/trace/check/flow-step-site.ts +234 -0
  660. package/src/trace/check/io-pattern.ts +116 -0
  661. package/src/trace/codemod/add-cardinality.ts +152 -0
  662. package/src/trace/codemod/run.ts +80 -0
  663. package/src/trace/entry/child-trace.ts +21 -0
  664. package/src/trace/entry/close-trace.ts +87 -0
  665. package/src/trace/entry/finalize-trace.ts +28 -0
  666. package/src/trace/entry/open-trace.ts +138 -0
  667. package/src/trace/entry/parent-trace.ts +20 -0
  668. package/src/trace/entry/parse-traceparent.ts +39 -0
  669. package/src/trace/entry/process-start.ts +38 -0
  670. package/src/trace/entry/trace-context.ts +26 -0
  671. package/src/trace/entry/tracing-enabled.ts +10 -0
  672. package/src/trace/external/record.ts +195 -0
  673. package/src/trace/external/report.ts +66 -0
  674. package/src/trace/namespace.ts +19 -0
  675. package/src/trace/partition/day.ts +47 -0
  676. package/src/trace/partition/index.ts +14 -0
  677. package/src/trace/partition/partition-name.ts +23 -0
  678. package/src/trace/partition/plan.ts +82 -0
  679. package/src/trace/partition/schedule.ts +26 -0
  680. package/src/trace/partition/startup-hook.ts +39 -0
  681. package/src/trace/partition/sync.ts +154 -0
  682. package/src/trace/partition/tables.ts +53 -0
  683. package/src/trace/read/tree.ts +83 -0
  684. package/src/trace/retain/copy-on-error.ts +76 -0
  685. package/src/trace/runtime/expire-stale-traces.ts +54 -0
  686. package/src/trace/runtime/flow-step.ts +147 -0
  687. package/src/trace/runtime/flush-steps.ts +31 -0
  688. package/src/trace/runtime/pending-writes.ts +54 -0
  689. package/src/trace/runtime/step-buffer.ts +298 -0
  690. package/src/trace/runtime/step-error.ts +21 -0
  691. package/src/trace/runtime/step-meta.ts +55 -0
  692. package/src/trace/runtime/thenable.ts +12 -0
  693. package/src/trace/runtime/truncate-trace.ts +62 -0
  694. package/src/trace/schema/trace-fields.ts +52 -0
  695. package/src/trace/schema/trace-retained.ts +22 -0
  696. package/src/trace/schema/trace-step-fields.ts +44 -0
  697. package/src/trace/schema/trace-step-retained.ts +25 -0
  698. package/src/trace/schema/trace-step.ts +18 -0
  699. package/src/trace/schema/trace.ts +12 -0
  700. package/src/trace/transformer/apply.ts +79 -0
  701. package/src/trace/transformer/build-meta-literal.ts +30 -0
  702. package/src/trace/transformer/find-flow-step-sites.ts +85 -0
  703. package/src/trace/transformer/inject-flow-step-import.ts +39 -0
  704. package/src/trace/transformer/transform-error.ts +14 -0
  705. package/src/trace/transformer/transform-flow-steps.ts +123 -0
  706. package/src/trace/transformer/wrap-function-declaration.ts +62 -0
  707. package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
  708. package/src/workspace/affected.ts +110 -0
  709. package/src/workspace/changed-files.ts +163 -0
  710. package/src/workspace/check-deps.ts +213 -0
  711. package/src/workspace/checksum.ts +20 -0
  712. package/src/workspace/dependency-graph.ts +67 -0
  713. package/src/workspace/detect-pipework-copies.ts +93 -0
  714. package/src/workspace/discover.ts +206 -0
  715. package/src/workspace/generate.ts +401 -0
  716. package/src/workspace/index.ts +7 -0
  717. package/src/workspace/module-imports-pipework.ts +39 -0
  718. package/src/workspace/module-package-name.ts +12 -0
  719. package/src/workspace/scrub-git-env.ts +23 -0
  720. package/src/workspace/source-paths.ts +36 -0
  721. package/src/workspace/sync.ts +182 -0
  722. package/dist/REFERENCE.md +0 -1477
  723. package/dist/infra/access-links/rate-limit.d.ts +0 -10
  724. package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
  725. package/dist/infra/access-links/rate-limit.js +0 -28
  726. package/dist/infra/access-links/rate-limit.js.map +0 -1
  727. package/dist/infra/email/sigv4.d.ts +0 -24
  728. package/dist/infra/email/sigv4.d.ts.map +0 -1
  729. package/dist/infra/email/sigv4.js +0 -55
  730. package/dist/infra/email/sigv4.js.map +0 -1
@@ -0,0 +1,1422 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import postgres from 'postgres'
3
+ import { drizzle } from '../data/query/postgres-js/index.js'
4
+ import { sql } from '../data/query/index.js'
5
+ import type { Manifold } from '../core/pipework.js'
6
+ import type { Blueprint, ResolvedDatabase } from '../core/config/index.js'
7
+ import { quoteIdentifier } from '../data/db/identifiers.js'
8
+ import { applyMigrations } from '../data/migrate/apply.js'
9
+ import { queueTableProblems, queueTableProblemMessage } from '../core/validation/queue-tables.js'
10
+ import { probeRlsRole, rlsProbeTarget, rlsRoleMessage } from '../core/validation/rls-role.js'
11
+ import { provisionFrameworkTables } from '../data/migrate/provision-framework.js'
12
+ import { getAdminUrl, reapOrphanTestDatabases, runLockKey, templateBuildKey, templateUseKey } from './reap.js'
13
+ import { testDbPrefix, appRolePrefix, templateDbPrefix } from './namespace.js'
14
+ import { templateFingerprint, templateInputs } from './fingerprint.js'
15
+ import {
16
+ PhaseTracker,
17
+ withStallWatchdog,
18
+ DEFAULT_SETUP_STALL_MS,
19
+ type SetupProgressListener,
20
+ } from './progress.js'
21
+ import { describeError } from '../core/logging/error-chain.js'
22
+ import { captureBaseline, resetDatabase, TEST_RESET_LOG_PREFIX, type Baseline } from './reset.js'
23
+
24
+ export interface TestDatabase {
25
+ name: string
26
+ url: string
27
+ appUrl: string
28
+ appRole: string
29
+ /** The per-file database that gets dropped on teardown. */
30
+ database: string
31
+ }
32
+
33
+ // A template per configured database, built once — migrations, extensions,
34
+ // framework tables, role grants — then every test file's database is a
35
+ // `CREATE DATABASE ... WITH TEMPLATE` clone of it. A clone is a filesystem copy
36
+ // (tens of ms); replaying the migration stack per file is seconds. The
37
+ // template's app role and its table/schema/default-privilege grants are
38
+ // catalog rows that the clone copies for free. Issue #321.
39
+ //
40
+ // The template is shared across PROCESSES, keyed by the hash of everything that
41
+ // determines its contents. A consumer running vitest with `pool: 'forks'` and
42
+ // `isolate: true` gets a fresh process per test file; when the template was
43
+ // keyed by pid and run nonce, every one of those processes replayed the entire
44
+ // migration stack for a byte-identical result. Now the first process to want a
45
+ // given content hash builds it and the rest clone from it, across processes and
46
+ // across runs. Per-file CLONES stay pid+nonce-keyed exactly as before — nothing
47
+ // about isolation between test files changes. Issue #340.
48
+ interface Template {
49
+ templateDb: string
50
+ appRole: string
51
+ fingerprint: string
52
+ }
53
+
54
+ /** One database's template identity, computed before any connection is opened. */
55
+ interface TemplatePlan {
56
+ name: string
57
+ dbConfig: ResolvedDatabase
58
+ fingerprint: string
59
+ templateDb: string
60
+ appRole: string
61
+ isDefaultDatabase: boolean
62
+ }
63
+
64
+ // Marker table written inside a template as the last act of a successful build.
65
+ // A template database that exists without this row is the wreckage of a builder
66
+ // that died mid-migration: it must be rebuilt, never cloned.
67
+ const TEMPLATE_MARKER_TABLE = 'pipework_test_template'
68
+
69
+ const testDatabases = new Map<string, TestDatabase>()
70
+
71
+ // A per-file database this process keeps across files, with the state it is
72
+ // returned to between them (`reset.ts`). Keyed by database name.
73
+ interface RetainedDatabase {
74
+ testDb: TestDatabase
75
+ baseline: Baseline
76
+ /** Which template, server and clone settings it was made from. */
77
+ key: string
78
+ inUse: boolean
79
+ /** Its file has ended; the next setup resets it before handing it out. */
80
+ needsReset: boolean
81
+ }
82
+ const retained = new Map<string, RetainedDatabase>()
83
+ let databasePerFile = false
84
+ let runAdminUrl = ''
85
+ let adminIsSuperuser: boolean | null = null
86
+
87
+ async function readSuperuser(): Promise<boolean> {
88
+ const rows = await adminClient!`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`
89
+ return rows[0]?.['rolsuper'] === true
90
+ }
91
+ const templates = new Map<string, Template>()
92
+ let adminClient: postgres.Sql | null = null
93
+
94
+ // A random per-process fingerprint, embedded in every database and role name
95
+ // this run creates. It keys the advisory lock the run holds for its lifetime,
96
+ // letting another run's reaper tell this run's databases apart from orphans.
97
+ const runNonce = randomBytes(6).toString('hex')
98
+ let lockClient: postgres.Sql | null = null
99
+ let reaped = false
100
+ let workerShutdownArmed = false
101
+
102
+ function templateDbName(baseName: string, fingerprint: string): string {
103
+ return `${templateDbPrefix()}${baseName}_${fingerprint}`
104
+ }
105
+
106
+ /** Log prefix for what the orphan reaper left alone (#391). */
107
+ export const TEST_REAP_LOG_PREFIX = 'pipework:test-reap'
108
+
109
+ // One name per CLONE, not per process. A finished database may still be queued
110
+ // for drop (see `pendingDrops`), so the next clone must not ask for its name.
111
+ let cloneSeq = 0
112
+
113
+ function testDbName(baseName: string): string {
114
+ return `${testDbPrefix()}${baseName}_${cloneSeq++}_${process.pid}_${runNonce}`
115
+ }
116
+
117
+ // The app role is named to match what the orphan reaper derives from the
118
+ // *template* database name (`reap.ts` `pairedTemplateRoleName`): the template
119
+ // outlives every per-file clone and every run, so when the reaper finally drops
120
+ // a stale template it drops this role with it. The role must be content-keyed
121
+ // too — its grants are baked into the template's catalog by OID, so a template
122
+ // shared across processes needs a role shared across processes.
123
+ function appRoleName(baseName: string, fingerprint: string): string {
124
+ return `${appRolePrefix()}${baseName}_${fingerprint}`
125
+ }
126
+
127
+ export interface SetupOptions {
128
+ /** Told about every step setup reaches, in order. Issue #380. */
129
+ onProgress?: SetupProgressListener
130
+ /** Overrides `test.setupStallMs`. Injectable for tests. */
131
+ stallMs?: number
132
+ /** How often the stall watchdog looks. Injectable for tests. */
133
+ stallPollMs?: number
134
+ }
135
+
136
+ export async function setupTestDatabases(
137
+ instance: Manifold,
138
+ options: SetupOptions = {},
139
+ ): Promise<Map<string, TestDatabase>> {
140
+ const tracker = new PhaseTracker(options.onProgress)
141
+ return withStallWatchdog(
142
+ () => runSetup(instance, tracker),
143
+ tracker,
144
+ {
145
+ stallMs: options.stallMs ?? instance.config.raw.test?.setupStallMs ?? DEFAULT_SETUP_STALL_MS,
146
+ ...(options.stallPollMs === undefined ? {} : { pollMs: options.stallPollMs }),
147
+ },
148
+ )
149
+ }
150
+
151
+ async function runSetup(instance: Manifold, tracker: PhaseTracker): Promise<Map<string, TestDatabase>> {
152
+ const databases = [...instance.config.databases()]
153
+
154
+ // D4b at test bootstrap: for an rls-configured database, the role tests
155
+ // actually execute on must not bypass RLS — otherwise every policy is inert
156
+ // and the suite proves nothing about isolation. Which connection that is
157
+ // depends on the isolation strategy: transaction isolation collapses every
158
+ // role onto the resolved `url` connection; database/container isolation
159
+ // resolves roles normally (`appUrl ?? url`). Runs before prepareRun so a
160
+ // refused bootstrap opens no admin connection and leaves no state behind.
161
+ for (const [name, dbConfig] of databases) {
162
+ if (dbConfig.rls === undefined) continue
163
+ tracker.reached('rls-probe', name)
164
+ const target = rlsProbeTarget(dbConfig, instance.config.isolationStrategy(name))
165
+ const probe = await namingDatabase(name, 'rls-probe', () => probeRlsRole(target, null))
166
+ if (probe.superuser || probe.bypassRls) {
167
+ throw new Error(
168
+ `[pipework] Test bootstrap failed:\n\n ${rlsRoleMessage(name, probe)}\n`,
169
+ )
170
+ }
171
+ }
172
+
173
+ // Template identities are pure functions of config and migration content, so
174
+ // they are computed before any connection opens — the reaper needs the set of
175
+ // templates this run intends to use before it decides which templates are
176
+ // stale enough to drop.
177
+ const today = new Date()
178
+ const defaultName = instance.config.databaseNames()[0]
179
+ const plans: TemplatePlan[] = databases.map(([name, dbConfig]) => {
180
+ const fingerprint = templateFingerprint(
181
+ templateInputs(name, dbConfig, instance.config.raw, name === defaultName, today),
182
+ )
183
+ return {
184
+ name,
185
+ dbConfig,
186
+ fingerprint,
187
+ templateDb: templateDbName(name, fingerprint),
188
+ appRole: appRoleName(name, fingerprint),
189
+ isDefaultDatabase: name === defaultName,
190
+ }
191
+ })
192
+
193
+ dropBatchSize = instance.config.raw.test?.dropBatchSize ?? DEFAULT_DROP_BATCH_SIZE
194
+ databasePerFile = instance.config.raw.test?.databasePerFile ?? false
195
+ const cloneGuards = resolveCloneGuards(instance.config.raw)
196
+
197
+ const firstDb = databases[0]
198
+ if (firstDb !== undefined) {
199
+ tracker.reached('run-prepare')
200
+ await prepareRun(getAdminUrl(firstDb[1].url), new Set(plans.map(plan => plan.templateDb)), tracker)
201
+ }
202
+
203
+ for (const plan of plans) {
204
+ const template = await ensureTemplate(instance, plan, tracker)
205
+ const key = JSON.stringify([plan.name, plan.dbConfig.url, template.templateDb, cloneGuards])
206
+ const kept = databasePerFile ? undefined : await takeKept(key)
207
+ if (kept !== undefined) {
208
+ testDatabases.set(plan.name, kept.testDb)
209
+ continue
210
+ }
211
+ tracker.reached('clone-start', plan.name)
212
+ const testDb = await cloneTestDatabase(plan.name, plan.dbConfig, template, cloneGuards,
213
+ n => tracker.reached('clone-start', plan.name, `retry ${n}`))
214
+ tracker.reached('clone-done', plan.name)
215
+ if (!databasePerFile) {
216
+ const baseline = await captureBaseline(withDatabase(runAdminUrl, testDb.database))
217
+ if (baseline.hidden.length === 0) {
218
+ retained.set(testDb.database, { testDb, baseline, key, inUse: true, needsReset: false })
219
+ } else {
220
+ // Rows the admin role cannot read cannot be restored: this file's clone is dropped, as with databasePerFile.
221
+ console.warn(`${TEST_RESET_LOG_PREFIX} db=${testDb.database} not kept reason=rls tables=${baseline.hidden.join(',')}`)
222
+ }
223
+ }
224
+ testDatabases.set(plan.name, testDb)
225
+ }
226
+
227
+ tracker.reached('ready')
228
+ return testDatabases
229
+ }
230
+
231
+ // Acquire this run's advisory lock and reap orphans before provisioning. The
232
+ // lock is taken before the first CREATE DATABASE so a concurrent run's reaper
233
+ // can never mistake this run's freshly-created, backend-less databases — the
234
+ // per-run clones included — for orphans. It is held on a dedicated
235
+ // connection (idle/lifetime timeouts off) so Postgres releases it only when
236
+ // this process dies, and is never unlocked per file: the template outlives
237
+ // every file's database, so the lock that protects it must outlive them too.
238
+ async function prepareRun(adminUrl: string, liveTemplates: ReadonlySet<string>, tracker: PhaseTracker): Promise<void> {
239
+ runAdminUrl = adminUrl
240
+ if (adminClient === null) {
241
+ adminClient = postgres(adminUrl, { max: 1, onnotice: () => {} })
242
+ }
243
+ if (lockClient === null) {
244
+ lockClient = postgres(adminUrl, { max: 1, idle_timeout: 0, max_lifetime: 0, onnotice: () => {} })
245
+ await lockClient`SELECT pg_advisory_lock(${runLockKey(runNonce).toString()}::bigint)`
246
+ }
247
+ armWorkerShutdown()
248
+ if (!reaped) {
249
+ reaped = true
250
+ try {
251
+ const result = await reapOrphanTestDatabases(adminClient, liveTemplates, {
252
+ onReap: (datname, i, total) => tracker.reached('run-reap', null, `${i}/${total} ${datname}`),
253
+ })
254
+ // #391: databases sharing this project's prefix that pipework did not create
255
+ // are left alone. Naming them once per run is what keeps a deliberate skip
256
+ // from looking like the reaper having nothing to do.
257
+ if (result.skippedDatabases.length > 0) {
258
+ process.stderr.write(
259
+ `${TEST_REAP_LOG_PREFIX} left alone (not created by pipework): ${result.skippedDatabases.join(', ')}\n`,
260
+ )
261
+ }
262
+ } catch {
263
+ // Reaping is best effort — never let stale-orphan cleanup break setup.
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Get the shared template for one database, building it only if no process has
270
+ * built it yet.
271
+ *
272
+ * The protocol, in order, and the order is the point:
273
+ *
274
+ * 1. Take a SHARED advisory lock on the template's content key, on this run's
275
+ * lifetime connection. That pins the template: the reaper drops a template
276
+ * only under an EXCLUSIVE lock, so from here until this process dies, no
277
+ * reaper can pull the template out from under a clone.
278
+ * 2. Ask whether the template is ready. Because the pin is already held, a
279
+ * `yes` cannot go stale.
280
+ * 3. If not ready, TRY the separate BUILD key without blocking. Winning it
281
+ * means nobody else is building: ask once more (a builder may have finished
282
+ * between step 2 and the lock) and then build. Losing it means someone else
283
+ * is building right now — so wait by re-asking whether the template is
284
+ * ready, never by queueing on the exclusive key. Under a batch of forks
285
+ * that is the difference between one lock queue everyone traverses in turn
286
+ * and one builder with N cheap pollers: waiters proceed on the shared
287
+ * use-lock alone, with no replay and no exclusive acquisition. The wait is
288
+ * bounded; overrunning it fails here, by name, instead of surfacing as a
289
+ * hook timeout somewhere else. Issue #362.
290
+ *
291
+ * There is deliberately no fallback: if the shared template cannot be built or
292
+ * validated, setup fails loudly. Quietly reverting to a private per-process
293
+ * build would re-hide exactly the cost this exists to remove. Issue #340.
294
+ */
295
+ async function ensureTemplate(instance: Manifold, plan: TemplatePlan, tracker: PhaseTracker): Promise<Template> {
296
+ const cached = templates.get(plan.name)
297
+ if (cached !== undefined) return cached
298
+
299
+ const useKey = templateUseKey(plan.fingerprint).toString()
300
+ const buildKey = templateBuildKey(plan.fingerprint).toString()
301
+
302
+ tracker.reached('template-pin', plan.name)
303
+ await lockClient!`SELECT pg_advisory_lock_shared(${useKey}::bigint)`
304
+
305
+ await awaitTemplateBuild({
306
+ buildKey,
307
+ timeoutMs: templateBuildWaitMs(instance),
308
+ isReady: () => templateIsReady(plan),
309
+ tryLock: async () => {
310
+ const rows = await lockClient!<{ locked: boolean }[]>`
311
+ SELECT pg_try_advisory_lock(${buildKey}::bigint) AS locked
312
+ `
313
+ return rows[0]?.locked === true
314
+ },
315
+ unlock: async () => {
316
+ await lockClient!`SELECT pg_advisory_unlock(${buildKey}::bigint)`
317
+ },
318
+ build: () => {
319
+ tracker.reached('template-build', plan.name)
320
+ return buildTemplate(instance, plan, tracker)
321
+ },
322
+ builderPid: () => advisoryLockHolder(buildKey),
323
+ onWait: polls => tracker.reached('template-wait', plan.name, `polls=${polls}`),
324
+ })
325
+
326
+ tracker.reached('template-ready', plan.name)
327
+
328
+ // Checked on every process, not just the one that happened to build. A
329
+ // template is keyed by the migrations that made it; the QUEUES a manifold
330
+ // registers are not part of that key, so two instances can legitimately share
331
+ // one template while disagreeing about which queue tables must exist. Running
332
+ // this only inside the build would make the check depend on who got there
333
+ // first — which is to say, it would silently stop running.
334
+ tracker.reached('queue-check', plan.name)
335
+ await assertQueueTables(instance, plan)
336
+
337
+ const template: Template = {
338
+ templateDb: plan.templateDb,
339
+ appRole: plan.appRole,
340
+ fingerprint: plan.fingerprint,
341
+ }
342
+ templates.set(plan.name, template)
343
+ return template
344
+ }
345
+
346
+ // D2 loudness at test bootstrap: a registered queue whose table did not come
347
+ // out of the migrations — or came out of an older one, missing a column the
348
+ // current definition requires — would otherwise fail at first insert or first
349
+ // claim, deep in a test.
350
+ // Checked on the database queue migrations are emitted into — the sole
351
+ // database, or the declared defaultDatabase (same rule as startup validation).
352
+ async function assertQueueTables(instance: Manifold, plan: TemplatePlan): Promise<void> {
353
+ const declaredNames = instance.config.databaseNames()
354
+ const queueDbName = declaredNames.length === 1 ? declaredNames[0] : instance.config.raw.defaultDatabase
355
+ if (plan.name !== queueDbName) return
356
+
357
+ const problems = await namingDatabase(
358
+ plan.name, 'queue-check', () => queueTableProblems(siblingUrl(plan.dbConfig, plan.templateDb), null),
359
+ )
360
+ if (problems.length > 0) {
361
+ throw new Error(
362
+ `[pipework] Test bootstrap failed:\n\n` +
363
+ problems.map(p => ` ${queueTableProblemMessage(p, plan.name)}`).join('\n') + '\n',
364
+ )
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Setup's probes leave the connect timeout to the driver (PGCONNECT_TIMEOUT,
370
+ * else 30 s), so a loaded server can be given longer than a pinned 5 s. When
371
+ * the probe still cannot connect, the error names the database and the step.
372
+ */
373
+ async function namingDatabase<T>(database: string, step: string, probe: () => Promise<T>): Promise<T> {
374
+ try {
375
+ return await probe()
376
+ } catch (err) {
377
+ if (!isTransientProbeFailure(err)) throw err
378
+ const reason = describeError(err).message
379
+ throw new Error(
380
+ `[pipework] Test bootstrap could not reach database "${database}" (${step}): ${reason}. ` +
381
+ `The connect timeout is PGCONNECT_TIMEOUT seconds (default 30).`,
382
+ { cause: err },
383
+ )
384
+ }
385
+ }
386
+
387
+ /** Connection URL for a database on the same server as `dbConfig.url`. */
388
+ function siblingUrl(dbConfig: ResolvedDatabase, database: string): string {
389
+ return withDatabase(dbConfig.url, database)
390
+ }
391
+
392
+ /** The same connection — role, password, server, parameters — pointed at `database`. */
393
+ function withDatabase(connectionUrl: string, database: string): string {
394
+ const url = new URL(connectionUrl)
395
+ url.pathname = `/${database}`
396
+ return url.toString()
397
+ }
398
+
399
+ /**
400
+ * Is this template complete and usable?
401
+ *
402
+ * `false` means absent or unbuilt — no database (3D000), no marker table
403
+ * (42P01), a fingerprint that is not ours, or a cluster missing the app or
404
+ * platform role the build makes — and the caller rebuilds under
405
+ * the exclusive build lock. A connection that merely failed (57P01 from a peer's
406
+ * terminate, ECONNRESET, refused, timed out) is NOT an answer: it is retried,
407
+ * and if it never answers it throws. Answering `false` there would let one
408
+ * fork's transient blip drop and rebuild the template every other fork is
409
+ * cloning from. Issue #340.
410
+ */
411
+ async function templateIsReady(plan: TemplatePlan): Promise<boolean> {
412
+ return probeTemplateReady(() => readTemplateMarker(plan))
413
+ }
414
+
415
+ /** One probe: connect, read the marker, disconnect. Throws on any failure. */
416
+ async function readTemplateMarker(plan: TemplatePlan): Promise<boolean> {
417
+ const client = postgres(siblingUrl(plan.dbConfig, plan.templateDb), {
418
+ max: 1,
419
+ onnotice: () => {},
420
+ })
421
+ try {
422
+ const rows = await client<{ fingerprint: string }[]>`
423
+ SELECT fingerprint FROM ${client(TEMPLATE_MARKER_TABLE)} LIMIT 1
424
+ `
425
+ if (rows[0]?.fingerprint !== plan.fingerprint) return false
426
+ // Roles are cluster-global and do not travel with the database, so a
427
+ // marked template whose roles are gone cannot be cloned. Issue #812.
428
+ const wanted = templateRoles(plan)
429
+ const present = await client<{ rolname: string }[]>`
430
+ SELECT rolname FROM pg_roles WHERE rolname IN ${client(wanted)}
431
+ `
432
+ return present.length === wanted.length
433
+ } finally {
434
+ await client.end().catch(() => {})
435
+ }
436
+ }
437
+
438
+ /** The cluster-global roles a clone of this template depends on. */
439
+ function templateRoles(plan: TemplatePlan): string[] {
440
+ const platformRole = plan.dbConfig.rls?.platformRole
441
+ return platformRole === undefined ? [plan.appRole] : [...new Set([plan.appRole, platformRole])]
442
+ }
443
+
444
+ /** The template is absent or unbuilt — a real `no`, and grounds for a rebuild. */
445
+ const TEMPLATE_ABSENT = new Set(['3D000', '42P01'])
446
+
447
+ /**
448
+ * The connection failed rather than answered. 57P01 is `admin_shutdown`: a peer
449
+ * terminated this backend. The rest are socket-level failures of the same kind.
450
+ */
451
+ const TEMPLATE_TRANSIENT = new Set([
452
+ '57P01', '57P02', '57P03', '08006', '08003', '08001',
453
+ 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'CONNECT_TIMEOUT', 'CONNECTION_CLOSED',
454
+ ])
455
+
456
+ function errCode(err: unknown): string | undefined {
457
+ const code = (err as { code?: unknown } | null)?.code
458
+ return typeof code === 'string' ? code : undefined
459
+ }
460
+
461
+ function isTransientProbeFailure(err: unknown): boolean {
462
+ if (err === null || typeof err !== 'object') return false
463
+ const code = errCode(err)
464
+ if (code !== undefined && TEMPLATE_TRANSIENT.has(code)) return true
465
+ return isTransientProbeFailure((err as { cause?: unknown }).cause)
466
+ }
467
+
468
+ /** A probe that never answered. Never a reason to rebuild. */
469
+ export class TemplateProbeFailed extends Error {
470
+ constructor(attempts: number, cause: unknown) {
471
+ super(`[pipework] Could not reach the shared test template after ${attempts} attempts`, { cause })
472
+ this.name = 'TemplateProbeFailed'
473
+ }
474
+ }
475
+
476
+ const PROBE_RETRY_DELAYS_MS: readonly number[] = [50, 150, 400, 1000]
477
+
478
+ /**
479
+ * Run `probe` until it answers. An absent/unbuilt template answers `false`; a
480
+ * transient connection failure is retried and then thrown as
481
+ * `TemplateProbeFailed`; anything else propagates. Injectable for tests.
482
+ */
483
+ export async function probeTemplateReady(
484
+ probe: () => Promise<boolean>,
485
+ sleep: (ms: number) => Promise<void> = ms => new Promise(resolve => setTimeout(resolve, ms)),
486
+ delaysMs: readonly number[] = PROBE_RETRY_DELAYS_MS,
487
+ ): Promise<boolean> {
488
+ for (let i = 0; ; i++) {
489
+ try {
490
+ return await probe()
491
+ } catch (err) {
492
+ const code = errCode(err)
493
+ if (code !== undefined && TEMPLATE_ABSENT.has(code)) return false
494
+ if (!isTransientProbeFailure(err)) throw err
495
+ if (i >= delaysMs.length) throw new TemplateProbeFailed(i + 1, err)
496
+ await sleep(delaysMs[i]!)
497
+ }
498
+ }
499
+ }
500
+
501
+ /** Default bound on waiting for another process's template build. */
502
+ const DEFAULT_TEMPLATE_BUILD_WAIT_MS = 120_000
503
+
504
+ /** Base gap between readiness re-asks while another process builds. */
505
+ const BUILD_POLL_INTERVAL_MS = 500
506
+
507
+ function templateBuildWaitMs(instance: Manifold): number {
508
+ return instance.config.raw.test?.templateBuildWaitMs ?? DEFAULT_TEMPLATE_BUILD_WAIT_MS
509
+ }
510
+
511
+ /** Waited out another process's template build. Names the key, the builder, the wait. */
512
+ export class TemplateBuildWaitTimeout extends Error {
513
+ constructor(buildKey: string, builderPid: number | null, waitedMs: number) {
514
+ const who = builderPid === null
515
+ ? 'unknown — no backend currently holds the key'
516
+ : String(builderPid)
517
+ super(
518
+ `[pipework] Test bootstrap failed:\n\n` +
519
+ ` Waited ${(waitedMs / 1000).toFixed(1)}s for another process to finish building the shared\n` +
520
+ ` test template, and it has not finished.\n\n` +
521
+ ` build lock key: ${buildKey}\n` +
522
+ ` builder pid: ${who}\n\n` +
523
+ ` Inspect that backend (pg_stat_activity), or raise the bound with\n` +
524
+ ` test.templateBuildWaitMs in pipework.config.ts.\n`,
525
+ )
526
+ this.name = 'TemplateBuildWaitTimeout'
527
+ }
528
+ }
529
+
530
+ export interface TemplateBuildWait {
531
+ buildKey: string
532
+ timeoutMs: number
533
+ /** Is the template built and ours? Never touches the build key. */
534
+ isReady: () => Promise<boolean>
535
+ /** Non-blocking `pg_try_advisory_lock` on the build key. */
536
+ tryLock: () => Promise<boolean>
537
+ unlock: () => Promise<void>
538
+ build: () => Promise<void>
539
+ builderPid: () => Promise<number | null>
540
+ sleep?: (ms: number) => Promise<void>
541
+ pollIntervalMs?: number
542
+ now?: () => number
543
+ /** Called before each wait for another process's build, with the poll count. */
544
+ onWait?: (polls: number) => void
545
+ }
546
+
547
+ /**
548
+ * Make sure the template exists, building it here only if no other process is
549
+ * already building it.
550
+ *
551
+ * Exactly one process per fingerprint builds: the one that wins the build key.
552
+ * Everyone else re-asks `isReady` on an interval — a poll, not a lock queue —
553
+ * so a batch of forks costs one build and N cheap probes rather than N serial
554
+ * acquisitions of the exclusive key. The try-lock in the loop is what recovers
555
+ * a builder that died: its lock goes with its connection, and the next poller
556
+ * wins the key and builds. Injectable for tests. Issue #362.
557
+ */
558
+ export async function awaitTemplateBuild(wait: TemplateBuildWait): Promise<'ready' | 'built'> {
559
+ const sleep = wait.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)))
560
+ const now = wait.now ?? (() => Date.now())
561
+ const interval = wait.pollIntervalMs ?? BUILD_POLL_INTERVAL_MS
562
+ const start = now()
563
+ let polls = 0
564
+
565
+ for (;;) {
566
+ if (await wait.isReady()) return 'ready'
567
+
568
+ if (await wait.tryLock()) {
569
+ try {
570
+ // A builder may have finished between the probe above and this lock.
571
+ if (await wait.isReady()) return 'ready'
572
+ await wait.build()
573
+ return 'built'
574
+ } finally {
575
+ await wait.unlock()
576
+ }
577
+ }
578
+
579
+ const waited = now() - start
580
+ if (waited >= wait.timeoutMs) {
581
+ throw new TemplateBuildWaitTimeout(wait.buildKey, await wait.builderPid(), waited)
582
+ }
583
+ // Jittered so a batch of forks does not re-probe in lockstep. Each wait is
584
+ // reported: a process queued behind a peer's build is making progress, and
585
+ // setup's stall watchdog must see that rather than count it as silence.
586
+ polls++
587
+ wait.onWait?.(polls)
588
+ await sleep(interval + Math.floor(Math.random() * interval))
589
+ }
590
+ }
591
+
592
+ /**
593
+ * The backend holding an advisory lock, or null. Diagnostic only: a failure to
594
+ * answer must never be the thing that fails a bootstrap — which is exactly why
595
+ * the predicate is covered by a real-Postgres test.
596
+ */
597
+ export async function advisoryLockHolder(
598
+ key: string,
599
+ client: postgres.Sql = adminClient!,
600
+ ): Promise<number | null> {
601
+ try {
602
+ const rows = await client<{ pid: number }[]>`
603
+ SELECT pid FROM pg_locks
604
+ WHERE locktype = 'advisory'
605
+ AND granted
606
+ AND objsubid = 1
607
+ AND ((classid::bigint << 32) | objid::bigint) = ${key}::bigint
608
+ LIMIT 1
609
+ `
610
+ return rows[0]?.pid ?? null
611
+ } catch {
612
+ return null
613
+ }
614
+ }
615
+
616
+ // Build one shared template from scratch. Runs under the exclusive build lock,
617
+ // with this process's shared use-lock already held. Issue #321, #340.
618
+ async function buildTemplate(instance: Manifold, plan: TemplatePlan, tracker: PhaseTracker): Promise<void> {
619
+ const { dbConfig } = plan
620
+ const templateDb = plan.templateDb
621
+ const templateIdent = quoteIdentifier(templateDb, 'database')
622
+ const admin = drizzle(adminClient!)
623
+
624
+ // A template database that exists at this point failed its readiness check —
625
+ // the leftovers of a builder that died. Drop and start over.
626
+ tracker.reached('template-build', plan.name, 'drop')
627
+ await closeTemplateClients(admin, templateDb)
628
+ await withTemplateRetry(
629
+ () => admin.execute(sql.raw(`DROP DATABASE IF EXISTS ${templateIdent}`)),
630
+ { prefix: TEMPLATE_DROP_LOG_PREFIX, onRetry: n => tracker.reached('template-build', plan.name, `drop retry ${n}`) },
631
+ )
632
+ tracker.reached('template-build', plan.name, 'create')
633
+ await admin.execute(sql.raw(`CREATE DATABASE ${templateIdent}`))
634
+ // #325: a test database is dropped or rolled back — durable commits are pure
635
+ // overhead. `synchronous_commit` is a per-database setting and does not copy
636
+ // with `WITH TEMPLATE`, so it is set on the template and on every clone.
637
+ await admin.execute(sql.raw(`ALTER DATABASE ${templateIdent} SET synchronous_commit = off`))
638
+
639
+ const templateUrlStr = siblingUrl(dbConfig, templateDb)
640
+
641
+ const templateClient = postgres(templateUrlStr, { max: 1, onnotice: () => {} })
642
+ const templateDbHandle = drizzle(templateClient)
643
+
644
+ // The template's migrations run as the configured migration owner, so every
645
+ // object a migration creates is owned by it — the one thing that decides
646
+ // what a `SECURITY DEFINER` function made by a migration can read, and the
647
+ // one thing test mode used to get differently from production. Everything
648
+ // else stays on this run's own connection: the template is created, cloned
649
+ // and connected to as `url`, which is what keeps the RLS bootstrap probe and
650
+ // tenant isolation biting exactly as before.
651
+ const migrateUrlStr = dbConfig.testOwnerUrl !== undefined
652
+ ? withDatabase(dbConfig.testOwnerUrl, templateDb)
653
+ : undefined
654
+ if (migrateUrlStr !== undefined) {
655
+ tracker.reached('template-build', plan.name, 'owner')
656
+ const ownerIdent = quoteIdentifier(new URL(migrateUrlStr).username, 'role')
657
+ await admin.execute(sql.raw(`GRANT CONNECT ON DATABASE ${templateIdent} TO ${ownerIdent}`))
658
+ // PG15+: `public` belongs to the database owner and grants CREATE to
659
+ // nobody. Without this the first migration fails on its first CREATE.
660
+ await templateDbHandle.execute(sql.raw(`GRANT CREATE, USAGE ON SCHEMA public TO ${ownerIdent}`))
661
+ }
662
+
663
+ // Door policies in the migrations say `TO <platform role>`, so the role must
664
+ // exist before they run.
665
+ if (dbConfig.rls?.platformRole !== undefined) {
666
+ tracker.reached('template-build', plan.name, 'platform role')
667
+ await provisionPlatformRole(adminClient!, dbConfig.rls.platformRole, new URL(templateUrlStr).username)
668
+ }
669
+
670
+ if (dbConfig.extensions.length > 0) {
671
+ for (const ext of dbConfig.extensions) {
672
+ tracker.reached('template-build', plan.name, `extension ${ext}`)
673
+ await templateDbHandle.execute(sql.raw(`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(ext, 'extension')}`))
674
+ }
675
+ }
676
+
677
+ if (dbConfig.migrations !== undefined) {
678
+ tracker.reached('template-migrate', plan.name, 'start')
679
+ await applyMigrations({
680
+ migrationsFolder: dbConfig.migrations,
681
+ migrationsTable: dbConfig.migrationTable,
682
+ connectionUrl: migrateUrlStr ?? templateUrlStr,
683
+ stripConcurrently: true,
684
+ unloggedTables: true,
685
+ // A migration stack is the one step long enough to look like a stall.
686
+ // Each file applied is an advance. Issue #380.
687
+ onMigration: (tag, index, total) =>
688
+ tracker.reached('template-migrate', plan.name, `${index}/${total} ${tag}`),
689
+ // Statements inside a long file, and the steps before the first file,
690
+ // are advances too. Issue #871.
691
+ onStatement: (tag, statement, statements, index, total) =>
692
+ tracker.reached('template-migrate', plan.name, `${index}/${total} ${tag} stmt ${statement}/${statements}`),
693
+ onPrepare: detail => tracker.reached('template-migrate', plan.name, `prepare ${detail}`),
694
+ })
695
+ }
696
+
697
+ // Framework-owned partitioned tables (trace family) are kept out of app
698
+ // migrations (#313) — pipework creates them and their day partitions here so
699
+ // a traced test never hits a missing relation or a missing partition.
700
+ tracker.reached('template-framework', plan.name)
701
+ await provisionFrameworkTables(
702
+ templateDbHandle,
703
+ instance.config.raw,
704
+ {
705
+ hotRetentionDays: instance.config.trace.hotRetentionDays,
706
+ errorRetentionDays: instance.config.trace.errorRetentionDays,
707
+ auditRetentionDays: instance.config.audit.retentionDays === 'keep' ? null : instance.config.audit.retentionDays,
708
+ partitionLeadDays: instance.config.trace.partitionLeadDays,
709
+ },
710
+ plan.isDefaultDatabase,
711
+ {
712
+ unlogged: true,
713
+ rls: dbConfig.rls,
714
+ // Every partition made is an advance: a year of audit days is hundreds
715
+ // of CREATEs, each of which can wait on the disk. Issue #806.
716
+ onProgress: (step, detail) => step === 'partition'
717
+ ? tracker.reached('template-partitions', plan.name, detail)
718
+ : tracker.reached('template-framework', plan.name, detail === undefined ? step : `${step} ${detail}`),
719
+ },
720
+ )
721
+
722
+ const grantBeat = (detail: string): void => tracker.reached('template-grants', plan.name, detail)
723
+ grantBeat('role')
724
+ await provisionTemplateRole(adminClient!, templateUrlStr, plan.appRole, migrateUrlStr, grantBeat)
725
+ if (dbConfig.rls !== undefined) await provisionTestSystemPolicy(templateUrlStr, migrateUrlStr, grantBeat)
726
+
727
+ // The marker is written last, and its presence is what makes this template
728
+ // clonable by any other process. The roles, grants and policies above must
729
+ // have succeeded first; a builder that dies before this point leaves a
730
+ // database that every reader correctly refuses to clone. Issue #812.
731
+ tracker.reached('template-framework', plan.name, 'marker')
732
+ await templateDbHandle.execute(sql.raw(
733
+ `CREATE TABLE ${quoteIdentifier(TEMPLATE_MARKER_TABLE, 'table')} (` +
734
+ `fingerprint text PRIMARY KEY, built_at timestamptz NOT NULL DEFAULT now(), builder_pid integer NOT NULL)`,
735
+ ))
736
+ await templateDbHandle.execute(sql`
737
+ INSERT INTO ${sql.identifier(TEMPLATE_MARKER_TABLE)} (fingerprint, builder_pid)
738
+ VALUES (${plan.fingerprint}, ${process.pid})
739
+ `)
740
+
741
+ await templateClient.end()
742
+ }
743
+
744
+ /**
745
+ * Create the platform role if the cluster lacks it: NOLOGIN, and never
746
+ * SUPERUSER or BYPASSRLS, which would make its door policies inert. The test
747
+ * login becomes a member so a test can `SET ROLE` to it. A role that already
748
+ * exists with either flag is refused rather than altered.
749
+ */
750
+ async function provisionPlatformRole(client: postgres.Sql, roleName: string, testLogin: string): Promise<void> {
751
+ const roleIdent = quoteIdentifier(roleName, 'role')
752
+ await refusingWithoutCreateRole(client, roleName, () => client.unsafe(
753
+ `DO $$ BEGIN ` +
754
+ `IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${roleName}') THEN ` +
755
+ `CREATE ROLE ${roleIdent} NOLOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; ` +
756
+ `END IF; ` +
757
+ `EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
758
+ ))
759
+ const [flags] = await client<{ rolsuper: boolean; rolbypassrls: boolean }[]>`
760
+ SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = ${roleName}
761
+ `
762
+ if (flags === undefined || flags.rolsuper || flags.rolbypassrls) {
763
+ throw new Error(
764
+ `[pipework] rls.platformRole "${roleName}" is ${flags?.rolsuper === true ? 'SUPERUSER' : 'BYPASSRLS'} on the test cluster — ` +
765
+ `row-level security never applies to it, so its doors would mean nothing. Use a role without either flag, or drop this one.`,
766
+ )
767
+ }
768
+ if (testLogin !== roleName) await client.unsafe(`GRANT ${roleIdent} TO ${quoteIdentifier(testLogin, 'role')}`)
769
+ }
770
+
771
+ /**
772
+ * Test template only: a permissive policy on every row-secured table that
773
+ * opens while `pipework.test_system` is `on`, which the test harness sets
774
+ * around statements sent through `pipe.system()`. It stands in for the owner's
775
+ * BYPASSRLS that production gives the system channel and a test login cannot
776
+ * have. Each policy is created by the table's owner, so the two grantors each
777
+ * take what they own.
778
+ */
779
+ async function provisionTestSystemPolicy(
780
+ templateUrl: string,
781
+ migrateUrl: string | undefined,
782
+ beat: (detail: string) => void,
783
+ ): Promise<void> {
784
+ for (const url of migrateUrl === undefined ? [templateUrl] : [templateUrl, migrateUrl]) {
785
+ const client = postgres(url, { max: 1, onnotice: () => {} })
786
+ try {
787
+ const tables = await client<{ ident: string; name: string }[]>`
788
+ SELECT c.oid::regclass::text AS ident, c.relname AS name
789
+ FROM pg_class c
790
+ JOIN pg_namespace n ON n.oid = c.relnamespace
791
+ WHERE c.relkind IN ('r', 'p')
792
+ AND c.relrowsecurity
793
+ AND NOT c.relispartition
794
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
795
+ AND n.nspname NOT LIKE 'pg\_toast%'
796
+ AND pg_get_userbyid(c.relowner) = current_user
797
+ AND NOT EXISTS (SELECT FROM pg_policy p WHERE p.polrelid = c.oid AND p.polname = 'pipework_test_system')
798
+ `
799
+ const open = `current_setting('pipework.test_system', true) = 'on'`
800
+ for (const [i, t] of tables.entries()) {
801
+ beat(`policy ${i + 1}/${tables.length} ${t.name}`)
802
+ await client.unsafe(`CREATE POLICY pipework_test_system ON ${t.ident} AS PERMISSIVE FOR ALL USING (${open}) WITH CHECK (${open})`)
803
+ }
804
+ } finally {
805
+ await client.end()
806
+ }
807
+ }
808
+ }
809
+
810
+ // Clone one test file's database from the shared template. A clone copies
811
+ // the template's tables, partitions, and in-database grants; only the cluster
812
+ // catalog's CONNECT privilege has to be re-granted on the new database. Issue #321.
813
+ // Session guards set on every per-test database. Teardown terminates a file's
814
+ // backends before dropping (#379), which covers an orderly finish. A process
815
+ // killed by pid is not covered: its backends keep running whatever statement
816
+ // they are inside, because Postgres learns the client is gone only when it
817
+ // next writes to the socket — a long statement never gets there. The orphan
818
+ // holds locks, raises 55006 against the template, and blocks the drop. These
819
+ // are set on the CLONE only, never on the template: a migration replay on the
820
+ // template is legitimately long, and per-database settings do not copy with
821
+ // `WITH TEMPLATE`. Issue #384.
822
+ interface CloneGuards {
823
+ readonly statementTimeoutMs: number
824
+ readonly idleInTransactionTimeoutMs: number
825
+ readonly clientCheckIntervalMs: number
826
+ }
827
+
828
+ const DEFAULT_STATEMENT_TIMEOUT_MS = 120_000
829
+ const DEFAULT_IDLE_IN_TRANSACTION_TIMEOUT_MS = 120_000
830
+ const DEFAULT_CLIENT_CHECK_INTERVAL_MS = 1_000
831
+
832
+ function resolveCloneGuards(raw: Blueprint): CloneGuards {
833
+ return {
834
+ statementTimeoutMs: raw.test?.statementTimeoutMs ?? DEFAULT_STATEMENT_TIMEOUT_MS,
835
+ idleInTransactionTimeoutMs: raw.test?.idleInTransactionTimeoutMs ?? DEFAULT_IDLE_IN_TRANSACTION_TIMEOUT_MS,
836
+ clientCheckIntervalMs: raw.test?.clientCheckIntervalMs ?? DEFAULT_CLIENT_CHECK_INTERVAL_MS,
837
+ }
838
+ }
839
+
840
+ /** The `ALTER DATABASE ... SET` statements for one clone. 0 means "leave it alone". */
841
+ export function cloneGuardStatements(dbIdent: string, guards: CloneGuards): string[] {
842
+ const settings: [string, number][] = [
843
+ ['statement_timeout', guards.statementTimeoutMs],
844
+ ['idle_in_transaction_session_timeout', guards.idleInTransactionTimeoutMs],
845
+ ['client_connection_check_interval', guards.clientCheckIntervalMs],
846
+ ]
847
+ return settings
848
+ .filter(([, ms]) => ms > 0)
849
+ .map(([name, ms]) => `ALTER DATABASE ${dbIdent} SET ${name} = ${ms}`)
850
+ }
851
+
852
+ async function cloneTestDatabase(name: string, dbConfig: ResolvedDatabase, template: Template, guards: CloneGuards, onRetry: (n: number) => void): Promise<TestDatabase> {
853
+ const dbName = testDbName(name)
854
+ const dbIdent = quoteIdentifier(dbName, 'database')
855
+ const templateIdent = quoteIdentifier(template.templateDb, 'database')
856
+ const roleIdent = quoteIdentifier(template.appRole, 'role')
857
+ const admin = drizzle(adminClient!)
858
+
859
+ // No `DROP DATABASE IF EXISTS` here. The name carries a per-clone sequence
860
+ // number, so it has never existed before — and a DROP of a database that DOES
861
+ // exist would force a checkpoint on the create path too (#379).
862
+ // CREATE ... WITH TEMPLATE refuses to run while any backend is connected to
863
+ // the template; close out stragglers, then create with a retry that waits out
864
+ // anything we couldn't (an autovacuum worker we have no right to terminate).
865
+ await closeTemplateClients(admin, template.templateDb)
866
+ await withTemplateRetry(() =>
867
+ admin.execute(sql.raw(`CREATE DATABASE ${dbIdent} WITH TEMPLATE ${templateIdent}`)),
868
+ { onRetry },
869
+ )
870
+ await admin.execute(sql.raw(`ALTER DATABASE ${dbIdent} SET synchronous_commit = off`))
871
+ for (const statement of cloneGuardStatements(dbIdent, guards)) {
872
+ await admin.execute(sql.raw(statement))
873
+ }
874
+ await admin.execute(sql.raw(`GRANT CONNECT ON DATABASE ${dbIdent} TO ${roleIdent}`))
875
+
876
+ const testUrl = new URL(dbConfig.url)
877
+ testUrl.pathname = `/${dbName}`
878
+
879
+ const appUrl = new URL(testUrl.toString())
880
+ appUrl.username = template.appRole
881
+ appUrl.password = 'pipework_test'
882
+
883
+ return {
884
+ name,
885
+ url: testUrl.toString(),
886
+ appUrl: appUrl.toString(),
887
+ appRole: template.appRole,
888
+ database: dbName,
889
+ }
890
+ }
891
+
892
+ // Close straggler client connections to the template so the clone can proceed.
893
+ // Three guards keep this from aborting the clone — or from sabotaging a peer:
894
+ // - `backend_type = 'client backend'` excludes background workers. An
895
+ // autovacuum worker attaches to every database and runs as superuser, and a
896
+ // non-superuser test role cannot signal it — targeting it would raise
897
+ // `permission denied to terminate process` (42501) and abort the statement.
898
+ // - `state = 'idle'` AND idle for 5s excludes backends doing work AND live
899
+ // peers. A peer fork's readiness probe is an idle client backend for a few
900
+ // ms between connecting and disconnecting; terminating it made the peer
901
+ // read the blip as "template absent" and rebuild the shared template out
902
+ // from under this clone. Only a backend idle for seconds is a straggler.
903
+ // - best-effort: if a client backend is still unkillable (owned by a role we
904
+ // cannot signal), swallow it. The `CREATE` retry waits out whatever remains.
905
+ async function closeTemplateClients(admin: ReturnType<typeof drizzle>, templateDb: string): Promise<void> {
906
+ try {
907
+ await admin.execute(sql`
908
+ SELECT pg_terminate_backend(pid)
909
+ FROM pg_stat_activity
910
+ WHERE datname = ${templateDb}
911
+ AND pid <> pg_backend_pid()
912
+ AND backend_type = 'client backend'
913
+ AND state = 'idle'
914
+ AND state_change < now() - interval '5 seconds'
915
+ `)
916
+ } catch {
917
+ // best-effort — withTemplateRetry tolerates any backend we couldn't close
918
+ }
919
+ }
920
+
921
+ // SQLSTATE 55006 (object_in_use): `CREATE DATABASE ... WITH TEMPLATE` raises this
922
+ // while any session is still attached to the template — including a transient
923
+ // autovacuum worker, which a non-superuser test role cannot terminate. The
924
+ // worker detaches in well under a second, so retry with capped backoff rather
925
+ // than depend on superuser termination rights.
926
+ const OBJECT_IN_USE = '55006'
927
+
928
+ // The query builder wraps the PostgresError, so the SQLSTATE may sit on `.cause`.
929
+ function isObjectInUse(err: unknown): boolean {
930
+ if (err === null || typeof err !== 'object') return false
931
+ return errCode(err) === OBJECT_IN_USE || isObjectInUse((err as { cause?: unknown }).cause)
932
+ }
933
+
934
+ // ~9.6 s in total: long enough to wait out real contention on a wide run,
935
+ // where several forks clone from one template and each probe attaches to it
936
+ // for a few ms.
937
+ const TEMPLATE_RETRY_DELAYS_MS: readonly number[] = [50, 100, 200, 400, 800, 1600, 1600, 1600, 1600, 1600]
938
+
939
+ // src/test has no logger — it runs before a Manifold exists — so retry lines go
940
+ // to stderr. Injectable so a test can read them without a global. Every line
941
+ // starts with the operation's prefix and carries key=value fields, so an infra
942
+ // script can grep the prefix and pull the numbers out without parsing prose.
943
+ // The two operations that retry 55006 get separate prefixes rather than one
944
+ // prefix and an op= field: a grep for template-clone then tallies clone
945
+ // contention only, and never a drop that a dead builder's leftovers forced.
946
+ export type RetryLog = (message: string) => void
947
+
948
+ export const TEMPLATE_CLONE_LOG_PREFIX = 'pipework:template-clone'
949
+ export const TEMPLATE_DROP_LOG_PREFIX = 'pipework:template-drop'
950
+
951
+ export interface TemplateRetryOptions {
952
+ /** Injectable for tests. */
953
+ sleep?: (ms: number) => Promise<void>
954
+ /** Injectable for tests. */
955
+ delaysMs?: readonly number[]
956
+ /** Injectable for tests. */
957
+ log?: RetryLog
958
+ /** Injectable for tests. */
959
+ now?: () => number
960
+ /** Names the operation the lines are about. Defaults to the clone prefix. */
961
+ prefix?: string
962
+ /** Called with the retry number before each backoff sleep. */
963
+ onRetry?: (n: number) => void
964
+ }
965
+
966
+ /**
967
+ * Run `attempt`, retrying only on SQLSTATE 55006 with capped exponential
968
+ * backoff. Any other error propagates immediately; 55006 past the last delay
969
+ * propagates too. Every retry, and the eventual recovery or exhaustion, is
970
+ * logged under `prefix`, so contention on the shared template is visible in a
971
+ * run's output and exhaustion is stated rather than inferred from a missing
972
+ * line; an attempt that succeeds first try says nothing. Reported elapsed is
973
+ * wall clock from before the first attempt, so it counts the failed
974
+ * round-trips, not just the sleeps.
975
+ */
976
+ export async function withTemplateRetry<T>(
977
+ attempt: () => Promise<T>,
978
+ options: TemplateRetryOptions = {},
979
+ ): Promise<T> {
980
+ const {
981
+ sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)),
982
+ delaysMs = TEMPLATE_RETRY_DELAYS_MS,
983
+ log = (message: string) => console.warn(message),
984
+ now = () => Date.now(),
985
+ prefix = TEMPLATE_CLONE_LOG_PREFIX,
986
+ onRetry,
987
+ } = options
988
+
989
+ const start = now()
990
+ for (let i = 0; ; i++) {
991
+ try {
992
+ const result = await attempt()
993
+ if (i > 0) {
994
+ log(`${prefix} recovered sqlstate=${OBJECT_IN_USE} retries=${i} elapsed_ms=${now() - start}`)
995
+ }
996
+ return result
997
+ } catch (err) {
998
+ // A non-55006 error is not a contention event and stays out of this
999
+ // channel; an exhausted ladder says so, so a tally never has to read a
1000
+ // fork killed mid-retry as an exhaustion.
1001
+ if (!isObjectInUse(err)) throw err
1002
+ if (i >= delaysMs.length) {
1003
+ log(`${prefix} exhausted sqlstate=${OBJECT_IN_USE} retries=${i} elapsed_ms=${now() - start}`)
1004
+ throw err
1005
+ }
1006
+ const delay = delaysMs[i]!
1007
+ log(
1008
+ `${prefix} retry sqlstate=${OBJECT_IN_USE} ` +
1009
+ `retry=${i + 1}/${delaysMs.length} delay_ms=${delay} elapsed_ms=${now() - start}`,
1010
+ )
1011
+ onRetry?.(i + 1)
1012
+ await sleep(delay)
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ const INSUFFICIENT_PRIVILEGE = '42501'
1018
+
1019
+ function hasSqlState(err: unknown, code: string): boolean {
1020
+ if (err === null || typeof err !== 'object') return false
1021
+ return errCode(err) === code || hasSqlState((err as { cause?: unknown }).cause, code)
1022
+ }
1023
+
1024
+ /** Run a CREATE ROLE; a refusal for lack of CREATEROLE names the login and the fix. Issue #812. */
1025
+ async function refusingWithoutCreateRole(client: postgres.Sql, roleName: string, create: () => PromiseLike<unknown>): Promise<void> {
1026
+ try {
1027
+ await create()
1028
+ } catch (err) {
1029
+ if (!hasSqlState(err, INSUFFICIENT_PRIVILEGE)) throw err
1030
+ const [who] = await client<{ login: string }[]>`SELECT current_user AS login`.catch(() => [])
1031
+ const login = who?.login ?? 'the test login'
1032
+ throw new Error(
1033
+ `[pipework] The test login "${login}" needs CREATEROLE on the test cluster to make role "${roleName}" ` +
1034
+ `(ALTER ROLE "${login}" CREATEROLE).`,
1035
+ { cause: err },
1036
+ )
1037
+ }
1038
+ }
1039
+
1040
+ // Create the app role (cluster-global, once per process) and grant it on the
1041
+ // template's tables, sequences, schema, and future objects. Those grants are
1042
+ // in-database catalog rows, so every clone of the template inherits them — the
1043
+ // role keeps a stable OID for the process lifetime, so the inherited ACL
1044
+ // entries stay valid. Issue #321.
1045
+ async function provisionTemplateRole(
1046
+ client: postgres.Sql,
1047
+ templateUrl: string,
1048
+ roleName: string,
1049
+ migrateUrl: string | undefined,
1050
+ beat: (detail: string) => void,
1051
+ ): Promise<void> {
1052
+ const admin = drizzle(client)
1053
+ const roleIdent = quoteIdentifier(roleName, 'role')
1054
+
1055
+ await refusingWithoutCreateRole(client, roleName, () => admin.execute(sql.raw(
1056
+ `DO $$ BEGIN ` +
1057
+ `IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${roleName}') THEN ` +
1058
+ `CREATE ROLE ${roleIdent} WITH LOGIN PASSWORD 'pipework_test' NOSUPERUSER NOCREATEDB NOCREATEROLE; ` +
1059
+ `END IF; ` +
1060
+ `END $$`,
1061
+ )))
1062
+
1063
+ const templateClient = postgres(templateUrl, { max: 1, onnotice: () => {} })
1064
+ const templateDb = drizzle(templateClient)
1065
+
1066
+ await templateDb.execute(sql.raw(`GRANT USAGE ON SCHEMA public TO ${roleIdent}`))
1067
+
1068
+ if (migrateUrl === undefined) {
1069
+ beat('tables')
1070
+ await templateDb.execute(sql.raw(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${roleIdent}`))
1071
+ await templateDb.execute(sql.raw(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${roleIdent}`))
1072
+ await templateDb.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${roleIdent}`))
1073
+ await templateDb.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${roleIdent}`))
1074
+ // Routines are granted here, after the migrations have run, because this is
1075
+ // the one place where roles are created AFTER the objects they must reach:
1076
+ // a migration that creates a function and revokes EXECUTE from PUBLIC would
1077
+ // otherwise leave the app role unable to call it.
1078
+ beat('routines')
1079
+ await templateDb.execute(sql.raw(`GRANT EXECUTE ON ALL ROUTINES IN SCHEMA public TO ${roleIdent}`))
1080
+ await templateDb.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON ROUTINES TO ${roleIdent}`))
1081
+ await templateClient.end()
1082
+ return
1083
+ }
1084
+
1085
+ // Two owners in one template: the migration owner made everything the
1086
+ // migrations made, this run's own role made the extensions' objects, the
1087
+ // framework tables. `GRANT ... ON ALL TABLES` refuses on the
1088
+ // first object its grantor does not own instead of skipping it, so each
1089
+ // grantor grants what it owns and nothing else.
1090
+ await grantOwnedObjects(templateClient, roleIdent, APP_ROLE_GRANTS, beat)
1091
+
1092
+ const migrateClient = postgres(migrateUrl, { max: 1, onnotice: () => {} })
1093
+ try {
1094
+ await grantOwnedObjects(migrateClient, roleIdent, APP_ROLE_GRANTS, beat)
1095
+ // The test login is the role tests connect as, and it no longer owns what
1096
+ // the migrations made. Everything short of ownership is granted back to
1097
+ // it; DDL that requires ownership (ALTER, DROP) needs the test login to be
1098
+ // a MEMBER of the migration owner, which is the venue's business — pipework
1099
+ // has no right to hand out role membership.
1100
+ await grantOwnedObjects(migrateClient, quoteIdentifier(new URL(templateUrl).username, 'role'), TEST_LOGIN_GRANTS, beat)
1101
+ } finally {
1102
+ await migrateClient.end()
1103
+ }
1104
+
1105
+ await templateClient.end()
1106
+ }
1107
+
1108
+ /** The privileges one grantor hands one role, by object kind. */
1109
+ interface GrantSet {
1110
+ readonly tables: string
1111
+ readonly sequences: string
1112
+ readonly routines: string
1113
+ }
1114
+
1115
+ const APP_ROLE_GRANTS: GrantSet = {
1116
+ tables: 'SELECT, INSERT, UPDATE, DELETE',
1117
+ sequences: 'USAGE, SELECT',
1118
+ routines: 'EXECUTE',
1119
+ }
1120
+
1121
+ const TEST_LOGIN_GRANTS: GrantSet = {
1122
+ tables: 'ALL PRIVILEGES',
1123
+ sequences: 'ALL PRIVILEGES',
1124
+ routines: 'EXECUTE',
1125
+ }
1126
+
1127
+ // Grant on the objects in `public` that THIS connection's role owns, plus the
1128
+ // default privileges for what it creates later. Ownership is read from the
1129
+ // catalog rather than assumed, so a template with two object owners is granted
1130
+ // correctly from either side and neither one's sweep can fail on the other's
1131
+ // objects. Object names come back as `regclass`/`regprocedure` text, already
1132
+ // quoted by Postgres and carrying a routine's argument types.
1133
+ async function grantOwnedObjects(
1134
+ client: postgres.Sql,
1135
+ roleIdent: string,
1136
+ grants: GrantSet,
1137
+ beat: (detail: string) => void,
1138
+ ): Promise<void> {
1139
+ const relations = await client<{ ident: string; kind: string }[]>`
1140
+ SELECT c.oid::regclass::text AS ident, c.relkind AS kind
1141
+ FROM pg_class c
1142
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1143
+ WHERE n.nspname = 'public'
1144
+ AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
1145
+ AND pg_get_userbyid(c.relowner) = current_user
1146
+ `
1147
+ const tables = relations.filter(r => r.kind !== 'S').map(r => r.ident)
1148
+ const sequences = relations.filter(r => r.kind === 'S').map(r => r.ident)
1149
+
1150
+ const routines = await client<{ ident: string }[]>`
1151
+ SELECT p.oid::regprocedure::text AS ident
1152
+ FROM pg_proc p
1153
+ JOIN pg_namespace n ON n.oid = p.pronamespace
1154
+ WHERE n.nspname = 'public'
1155
+ AND pg_get_userbyid(p.proowner) = current_user
1156
+ `
1157
+
1158
+ if (tables.length > 0) {
1159
+ await client.unsafe(`GRANT ${grants.tables} ON TABLE ${tables.join(', ')} TO ${roleIdent}`)
1160
+ }
1161
+ if (sequences.length > 0) {
1162
+ await client.unsafe(`GRANT ${grants.sequences} ON SEQUENCE ${sequences.join(', ')} TO ${roleIdent}`)
1163
+ }
1164
+ for (const [i, routine] of routines.entries()) {
1165
+ beat(`routine ${i + 1}/${routines.length} ${routine.ident}`)
1166
+ await client.unsafe(`GRANT ${grants.routines} ON ROUTINE ${routine.ident} TO ${roleIdent}`)
1167
+ }
1168
+
1169
+ await client.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ${grants.tables} ON TABLES TO ${roleIdent}`)
1170
+ await client.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ${grants.sequences} ON SEQUENCES TO ${roleIdent}`)
1171
+ await client.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ${grants.routines} ON ROUTINES TO ${roleIdent}`)
1172
+ }
1173
+
1174
+ // Close this run's persistent admin and lock connections. Releasing
1175
+ // `lockClient` drops the run's advisory lock — so this runs exactly once, at
1176
+ // worker shutdown, never per file: these connections hold the run's advisory
1177
+ // lock and this run's shared pin on every template it uses, and both must
1178
+ // outlive every file in the worker.
1179
+ async function closeRunConnections(): Promise<void> {
1180
+ if (adminClient !== null) {
1181
+ await adminClient.end()
1182
+ adminClient = null
1183
+ adminIsSuperuser = null
1184
+ }
1185
+ if (lockClient !== null) {
1186
+ await lockClient.end()
1187
+ lockClient = null
1188
+ }
1189
+ }
1190
+
1191
+ // Arm a one-time close of the persistent connections at worker shutdown.
1192
+ // Under `pool: 'forks'` each vitest worker is a forked child with an IPC
1193
+ // channel; vitest disconnects that channel when the worker has no more files,
1194
+ // firing `disconnect` — the only once-per-worker signal, since a per-file
1195
+ // `afterAll` cannot know which file is the worker's last. The admin and lock
1196
+ // connections are opened with no idle timeout, so their open sockets keep the
1197
+ // worker's event loop alive; closing them here lets the worker exit naturally
1198
+ // (#774). Holding them until then keeps the advisory lock for the worker's
1199
+ // whole life, so a sibling worker's reaper can never mistake this run's live
1200
+ // template for an orphan. Issue #321. Vitest 4 never sends `disconnect` to a
1201
+ // fork (it SIGTERMs it), so there the plugin's `reap-after-run` drops the
1202
+ // remainder from the parent instead (#810).
1203
+ function armWorkerShutdown(): void {
1204
+ if (workerShutdownArmed) return
1205
+ workerShutdownArmed = true
1206
+ if (typeof process.send === 'function') {
1207
+ process.once('disconnect', () => {
1208
+ // Flush FIRST: the drops need the admin connection this then closes.
1209
+ void flushPendingDrops('shutdown').finally(() => closeRunConnections())
1210
+ })
1211
+ }
1212
+ }
1213
+
1214
+ // Drop this file's clones. The shared template and its app role deliberately
1215
+ // outlive teardown — the next file, the next process, and the next run all
1216
+ // reuse it, and a reaper drops it only once its content key goes stale (#340). The run's admin and lock
1217
+ // connections also survive: `lockClient` holds the advisory lock that protects
1218
+ // the live template from a sibling worker's reaper, so it must stay open for
1219
+ // the worker's whole life — `armWorkerShutdown` closes both once, at worker
1220
+ // exit. A non-forked run has no IPC channel and so no `disconnect` signal, but
1221
+ // also no sibling worker and so no concurrent reaper: closing the connections
1222
+ // per file is then both safe and necessary for the process to exit. Issue #321.
1223
+ export async function teardownTestDatabases(): Promise<void> {
1224
+ if (adminClient === null) {
1225
+ testDatabases.clear()
1226
+ return
1227
+ }
1228
+
1229
+ const admin = drizzle(adminClient)
1230
+
1231
+ // Kept databases are reset by the next setup that takes them, not here: a
1232
+ // process that ends after one file never pays for it. Without an IPC channel
1233
+ // nothing follows this teardown (see below): drop instead.
1234
+ const canReuse = typeof process.send === 'function'
1235
+ for (const [, testDb] of testDatabases) {
1236
+ try {
1237
+ await admin.execute(sql`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ${testDb.database} AND pid != pg_backend_pid()`)
1238
+ const keep = retained.get(testDb.database)
1239
+ if (keep !== undefined && canReuse) {
1240
+ keep.inUse = false
1241
+ keep.needsReset = true
1242
+ } else {
1243
+ retained.delete(testDb.database)
1244
+ pendingDrops.push(testDb.database)
1245
+ }
1246
+ } catch {
1247
+ // Best effort cleanup — a leftover clone is reaped by the next run.
1248
+ }
1249
+ }
1250
+
1251
+ testDatabases.clear()
1252
+
1253
+ if (pendingDrops.length >= dropBatchSize) {
1254
+ await flushPendingDrops('batch')
1255
+ }
1256
+
1257
+ if (typeof process.send !== 'function') {
1258
+ // No IPC channel means no `disconnect` signal and so no later flush: this
1259
+ // teardown is the last chance to settle what is queued.
1260
+ await flushPendingDrops('shutdown')
1261
+ await closeRunConnections()
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * An idle kept database for this key, reset and marked in use. One whose reset
1267
+ * fails is queued for drop and the next candidate tried; none left means the
1268
+ * caller clones.
1269
+ */
1270
+ async function takeKept(key: string): Promise<RetainedDatabase | undefined> {
1271
+ for (const keep of retained.values()) {
1272
+ if (keep.key !== key || keep.inUse) continue
1273
+ if (keep.needsReset && !(await renameKept(keep) && await resetKept(keep))) {
1274
+ retained.delete(keep.testDb.database)
1275
+ pendingDrops.push(keep.testDb.database)
1276
+ continue
1277
+ }
1278
+ keep.needsReset = false
1279
+ keep.inUse = true
1280
+ return keep
1281
+ }
1282
+ return undefined
1283
+ }
1284
+
1285
+ /**
1286
+ * Give a kept database a new name before its next file, so that anything the
1287
+ * last file left running — a timer, a job loop, a pool — and that connects by
1288
+ * the old URL finds no database, as it would with a dropped clone, instead of
1289
+ * writing into the next file's. Sessions still attached are ended first; the
1290
+ * rename waits out one that attaches in between. False: it cannot be renamed
1291
+ * and needs a fresh clone.
1292
+ */
1293
+ async function renameKept(keep: RetainedDatabase): Promise<boolean> {
1294
+ const oldName = keep.testDb.database
1295
+ const newName = testDbName(keep.testDb.name)
1296
+ const admin = drizzle(adminClient!)
1297
+ for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt++) {
1298
+ try {
1299
+ await admin.execute(sql`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ${oldName} AND pid <> pg_backend_pid()`)
1300
+ await admin.execute(sql.raw(`ALTER DATABASE ${quoteIdentifier(oldName, 'database')} RENAME TO ${quoteIdentifier(newName, 'database')}`))
1301
+ } catch {
1302
+ await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)))
1303
+ continue
1304
+ }
1305
+ retained.delete(oldName)
1306
+ keep.testDb = {
1307
+ ...keep.testDb,
1308
+ database: newName,
1309
+ url: withDatabase(keep.testDb.url, newName),
1310
+ appUrl: withDatabase(keep.testDb.appUrl, newName),
1311
+ }
1312
+ retained.set(newName, keep)
1313
+ return true
1314
+ }
1315
+ console.warn(`${TEST_RESET_LOG_PREFIX} db=${oldName} fresh-clone reason=rename`)
1316
+ return false
1317
+ }
1318
+
1319
+ const RENAME_ATTEMPTS = 5
1320
+
1321
+ /** Reset a kept database to its baseline and say so. False: it needs a fresh clone. */
1322
+ async function resetKept(keep: RetainedDatabase): Promise<boolean> {
1323
+ const start = Date.now()
1324
+ adminIsSuperuser ??= await readSuperuser()
1325
+ const outcome = await resetDatabase(withDatabase(runAdminUrl, keep.testDb.database), keep.baseline, adminIsSuperuser)
1326
+ const db = keep.testDb.database
1327
+ console.warn(outcome.ok
1328
+ ? `${TEST_RESET_LOG_PREFIX} db=${db} cleared=${outcome.cleared} reloaded=${outcome.reloaded} elapsed_ms=${Date.now() - start}`
1329
+ : `${TEST_RESET_LOG_PREFIX} db=${db} fresh-clone reason=${outcome.reason}`)
1330
+ return outcome.ok
1331
+ }
1332
+
1333
+ // Per-file databases that are done with, waiting to be dropped together.
1334
+ //
1335
+ // A worker keeps its databases across files (`test.databasePerFile` off), so
1336
+ // what reaches this queue is a database whose reset fell back to a fresh clone,
1337
+ // every kept database at worker shutdown, and every database with
1338
+ // `databasePerFile`.
1339
+ //
1340
+ // Every `DROP DATABASE` forces an IMMEDIATE cluster-wide checkpoint and waits
1341
+ // for it — `RequestCheckpoint(CHECKPOINT_IMMEDIATE | FORCE | WAIT)` in
1342
+ // Postgres's own `dropdb()`. `CREATE DATABASE` does not: since PG15 its default
1343
+ // strategy is WAL_LOG, which needs no checkpoint at all. So the drop, not the
1344
+ // clone, is what a test file pays for, and it pays SYNCHRONOUSLY inside the
1345
+ // teardown hook: on a saturated cluster those checkpoints were measured at
1346
+ // 20-47 s apiece, which is the teardown hook timing out.
1347
+ //
1348
+ // The count of checkpoints is fixed by the count of databases — batching cannot
1349
+ // change it. What batching changes is WHERE the wait lands: one teardown in
1350
+ // `dropBatchSize` pays it instead of every one, and the rest of a worker's
1351
+ // drops are settled at worker shutdown, off the critical path of a wide start
1352
+ // burst. A worker that dies with drops still queued leaks nothing permanent —
1353
+ // its run lock dies with it and the next run's reaper drops them. Issue #379.
1354
+ const pendingDrops: string[] = []
1355
+
1356
+ const DEFAULT_DROP_BATCH_SIZE = 8
1357
+
1358
+ let dropBatchSize = DEFAULT_DROP_BATCH_SIZE
1359
+
1360
+ export const TEST_DROP_LOG_PREFIX = 'pipework:test-drop'
1361
+
1362
+ /**
1363
+ * Drop every queued database, back to back. Best effort per database: one that
1364
+ * refuses (a backend attached between teardown and here) stays for the reaper
1365
+ * rather than failing the flush for the rest.
1366
+ */
1367
+ export async function flushPendingDrops(
1368
+ reason: 'batch' | 'shutdown',
1369
+ log: RetryLog = message => console.warn(message),
1370
+ now: () => number = () => Date.now(),
1371
+ ): Promise<number> {
1372
+ if (reason === 'shutdown') {
1373
+ pendingDrops.push(...retained.keys())
1374
+ retained.clear()
1375
+ }
1376
+ if (adminClient === null || pendingDrops.length === 0) return 0
1377
+
1378
+ const admin = drizzle(adminClient)
1379
+ const batch = pendingDrops.splice(0, pendingDrops.length)
1380
+ const start = now()
1381
+ let dropped = 0
1382
+
1383
+ for (const database of batch) {
1384
+ try {
1385
+ await admin.execute(sql.raw(`DROP DATABASE IF EXISTS ${quoteIdentifier(database, 'database')}`))
1386
+ dropped++
1387
+ } catch {
1388
+ // Left for the next run's reaper.
1389
+ }
1390
+ }
1391
+
1392
+ // Each drop is a forced checkpoint the cluster had to wait out, so what the
1393
+ // batch cost is worth stating — an infra script greps the prefix and reads
1394
+ // the numbers without parsing prose, as the template-retry lines do.
1395
+ log(`${TEST_DROP_LOG_PREFIX} ${reason} queued=${batch.length} dropped=${dropped} elapsed_ms=${now() - start}`)
1396
+ return dropped
1397
+ }
1398
+
1399
+ /** Test-only: how many databases are waiting to be dropped. */
1400
+ export function pendingDropCount(): number {
1401
+ return pendingDrops.length
1402
+ }
1403
+
1404
+ export function getTestDatabase(name: string): TestDatabase | undefined {
1405
+ return testDatabases.get(name)
1406
+ }
1407
+
1408
+ export function getTestDatabases(): ReadonlyMap<string, TestDatabase> {
1409
+ return testDatabases
1410
+ }
1411
+
1412
+ /**
1413
+ * Forget the in-process template cache, so the next `setupTestDatabases` takes
1414
+ * the same path a freshly-forked worker takes: shared lock, readiness check,
1415
+ * clone. This is how a single process can prove the cross-process claim — that
1416
+ * a second fork reuses the template rather than rebuilding it — without
1417
+ * actually forking. Test-only. Issue #340.
1418
+ */
1419
+ export function forgetTemplateCacheForTests(): void {
1420
+ templates.clear()
1421
+ reaped = false
1422
+ }