@satorio/machinery 0.2.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 (341) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/dictionary/all.json +4656 -0
  4. package/dictionary/all.toon +639 -0
  5. package/dictionary/core.json +221 -0
  6. package/dictionary/core.toon +33 -0
  7. package/dictionary/graphql.json +212 -0
  8. package/dictionary/graphql.toon +32 -0
  9. package/dictionary/http.json +113 -0
  10. package/dictionary/http.toon +21 -0
  11. package/dictionary/identifiers.json +266 -0
  12. package/dictionary/identifiers.toon +38 -0
  13. package/dictionary/index.json +96 -0
  14. package/dictionary/index.toon +18 -0
  15. package/dictionary/integration.json +131 -0
  16. package/dictionary/integration.toon +23 -0
  17. package/dictionary/money.json +383 -0
  18. package/dictionary/money.toon +51 -0
  19. package/dictionary/ports.json +392 -0
  20. package/dictionary/ports.toon +52 -0
  21. package/dictionary/postgres.json +473 -0
  22. package/dictionary/postgres.toon +61 -0
  23. package/dictionary/publishing.json +131 -0
  24. package/dictionary/publishing.toon +23 -0
  25. package/dictionary/queue.json +365 -0
  26. package/dictionary/queue.toon +49 -0
  27. package/dictionary/reference/api.mdx +843 -0
  28. package/dictionary/reference/uk/api.mdx +844 -0
  29. package/dictionary/storage.json +194 -0
  30. package/dictionary/storage.toon +30 -0
  31. package/dictionary/testing.json +707 -0
  32. package/dictionary/testing.toon +87 -0
  33. package/dictionary/time.json +311 -0
  34. package/dictionary/time.toon +43 -0
  35. package/dictionary/vitest.json +356 -0
  36. package/dictionary/vitest.toon +48 -0
  37. package/dictionary/workflow.json +185 -0
  38. package/dictionary/workflow.toon +29 -0
  39. package/dist/bin/command.d.ts +45 -0
  40. package/dist/bin/command.js +113 -0
  41. package/dist/bin.d.ts +2 -0
  42. package/dist/bin.js +11 -0
  43. package/dist/core/bigint-json.d.ts +6 -0
  44. package/dist/core/bigint-json.js +6 -0
  45. package/dist/core/closed-set.d.ts +14 -0
  46. package/dist/core/closed-set.js +8 -0
  47. package/dist/core/domain-error.d.ts +8 -0
  48. package/dist/core/domain-error.js +11 -0
  49. package/dist/core/environment.d.ts +124 -0
  50. package/dist/core/environment.js +115 -0
  51. package/dist/core/index.d.ts +13 -0
  52. package/dist/core/index.js +13 -0
  53. package/dist/core/ulid.d.ts +27 -0
  54. package/dist/core/ulid.js +27 -0
  55. package/dist/graphql/builder.d.ts +36 -0
  56. package/dist/graphql/builder.js +35 -0
  57. package/dist/graphql/context.d.ts +36 -0
  58. package/dist/graphql/context.js +69 -0
  59. package/dist/graphql/failure-classification.d.ts +11 -0
  60. package/dist/graphql/failure-classification.js +1 -0
  61. package/dist/graphql/failures.d.ts +62 -0
  62. package/dist/graphql/failures.js +30 -0
  63. package/dist/graphql/graphiql.d.ts +45 -0
  64. package/dist/graphql/graphiql.js +56 -0
  65. package/dist/graphql/index.d.ts +15 -0
  66. package/dist/graphql/index.js +14 -0
  67. package/dist/graphql/layer.d.ts +15 -0
  68. package/dist/graphql/layer.js +23 -0
  69. package/dist/graphql/request-handler.d.ts +26 -0
  70. package/dist/graphql/request-handler.js +33 -0
  71. package/dist/graphql/server.d.ts +19 -0
  72. package/dist/graphql/server.js +16 -0
  73. package/dist/graphql/settings.d.ts +55 -0
  74. package/dist/graphql/settings.js +82 -0
  75. package/dist/http/health.d.ts +70 -0
  76. package/dist/http/health.js +60 -0
  77. package/dist/http/index.d.ts +5 -0
  78. package/dist/http/index.js +5 -0
  79. package/dist/identifiers/client-code.d.ts +37 -0
  80. package/dist/identifiers/client-code.js +41 -0
  81. package/dist/identifiers/creditor-reference.d.ts +38 -0
  82. package/dist/identifiers/creditor-reference.js +32 -0
  83. package/dist/identifiers/damm.d.ts +22 -0
  84. package/dist/identifiers/damm.js +50 -0
  85. package/dist/identifiers/document-number.d.ts +68 -0
  86. package/dist/identifiers/document-number.js +73 -0
  87. package/dist/identifiers/document-numbers.d.ts +27 -0
  88. package/dist/identifiers/document-numbers.js +16 -0
  89. package/dist/identifiers/failures.d.ts +46 -0
  90. package/dist/identifiers/failures.js +48 -0
  91. package/dist/identifiers/index.d.ts +14 -0
  92. package/dist/identifiers/index.js +13 -0
  93. package/dist/identifiers/next-document-number.d.ts +26 -0
  94. package/dist/identifiers/next-document-number.js +31 -0
  95. package/dist/identifiers/ulid.d.ts +28 -0
  96. package/dist/identifiers/ulid.js +71 -0
  97. package/dist/integration/external-http-client.d.ts +22 -0
  98. package/dist/integration/external-http-client.js +74 -0
  99. package/dist/integration/index.d.ts +12 -0
  100. package/dist/integration/index.js +12 -0
  101. package/dist/integration/layer.d.ts +19 -0
  102. package/dist/integration/layer.js +30 -0
  103. package/dist/integration/retry.d.ts +65 -0
  104. package/dist/integration/retry.js +54 -0
  105. package/dist/integration/settings.d.ts +73 -0
  106. package/dist/integration/settings.js +92 -0
  107. package/dist/money/amount.d.ts +60 -0
  108. package/dist/money/amount.js +67 -0
  109. package/dist/money/arithmetic.d.ts +60 -0
  110. package/dist/money/arithmetic.js +73 -0
  111. package/dist/money/currency.d.ts +25 -0
  112. package/dist/money/currency.js +24 -0
  113. package/dist/money/decimal.d.ts +29 -0
  114. package/dist/money/decimal.js +39 -0
  115. package/dist/money/exchange-rate.d.ts +32 -0
  116. package/dist/money/exchange-rate.js +28 -0
  117. package/dist/money/failures.d.ts +86 -0
  118. package/dist/money/failures.js +97 -0
  119. package/dist/money/index.d.ts +15 -0
  120. package/dist/money/index.js +15 -0
  121. package/dist/money/percent.d.ts +35 -0
  122. package/dist/money/percent.js +50 -0
  123. package/dist/money/schema.d.ts +25 -0
  124. package/dist/money/schema.js +41 -0
  125. package/dist/ports/blob-store.d.ts +42 -0
  126. package/dist/ports/blob-store.js +13 -0
  127. package/dist/ports/document-number-counter.d.ts +36 -0
  128. package/dist/ports/document-number-counter.js +12 -0
  129. package/dist/ports/event-publisher.d.ts +29 -0
  130. package/dist/ports/event-publisher.js +13 -0
  131. package/dist/ports/external-http-client.d.ts +60 -0
  132. package/dist/ports/external-http-client.js +25 -0
  133. package/dist/ports/index.d.ts +17 -0
  134. package/dist/ports/index.js +17 -0
  135. package/dist/ports/integration-event-outbox.d.ts +44 -0
  136. package/dist/ports/integration-event-outbox.js +13 -0
  137. package/dist/ports/port-unavailable.d.ts +21 -0
  138. package/dist/ports/port-unavailable.js +30 -0
  139. package/dist/ports/processed-event-registry.d.ts +57 -0
  140. package/dist/ports/processed-event-registry.js +24 -0
  141. package/dist/ports/queue-message-producer.d.ts +26 -0
  142. package/dist/ports/queue-message-producer.js +12 -0
  143. package/dist/ports/queue-message-source.d.ts +39 -0
  144. package/dist/ports/queue-message-source.js +13 -0
  145. package/dist/ports/readiness-probe.d.ts +27 -0
  146. package/dist/ports/readiness-probe.js +13 -0
  147. package/dist/ports/transaction-boundary.d.ts +28 -0
  148. package/dist/ports/transaction-boundary.js +13 -0
  149. package/dist/postgres/client.d.ts +51 -0
  150. package/dist/postgres/client.js +81 -0
  151. package/dist/postgres/document-number-counter.d.ts +14 -0
  152. package/dist/postgres/document-number-counter.js +27 -0
  153. package/dist/postgres/failures.d.ts +28 -0
  154. package/dist/postgres/failures.js +18 -0
  155. package/dist/postgres/index.d.ts +18 -0
  156. package/dist/postgres/index.js +18 -0
  157. package/dist/postgres/integration-event-outbox.d.ts +15 -0
  158. package/dist/postgres/integration-event-outbox.js +60 -0
  159. package/dist/postgres/jsonb-value.d.ts +10 -0
  160. package/dist/postgres/jsonb-value.js +9 -0
  161. package/dist/postgres/layer.d.ts +57 -0
  162. package/dist/postgres/layer.js +72 -0
  163. package/dist/postgres/migrations/advisory-lock.d.ts +47 -0
  164. package/dist/postgres/migrations/advisory-lock.js +59 -0
  165. package/dist/postgres/migrations/runner.d.ts +56 -0
  166. package/dist/postgres/migrations/runner.js +69 -0
  167. package/dist/postgres/migrations/sql-file-loader.d.ts +59 -0
  168. package/dist/postgres/migrations/sql-file-loader.js +99 -0
  169. package/dist/postgres/processed-event-registry.d.ts +13 -0
  170. package/dist/postgres/processed-event-registry.js +44 -0
  171. package/dist/postgres/readiness-probe.d.ts +27 -0
  172. package/dist/postgres/readiness-probe.js +33 -0
  173. package/dist/postgres/settings.d.ts +134 -0
  174. package/dist/postgres/settings.js +210 -0
  175. package/dist/postgres/sql/0001_processed_event.sql +4 -0
  176. package/dist/postgres/sql/0002_integration_event_outbox.sql +13 -0
  177. package/dist/postgres/sql/0003_document_number_counter.sql +6 -0
  178. package/dist/postgres/sql-failure.d.ts +19 -0
  179. package/dist/postgres/sql-failure.js +22 -0
  180. package/dist/postgres/store-failure.d.ts +17 -0
  181. package/dist/postgres/store-failure.js +16 -0
  182. package/dist/postgres/transaction-boundary.d.ts +13 -0
  183. package/dist/postgres/transaction-boundary.js +21 -0
  184. package/dist/publishing/event-publisher.sns.d.ts +66 -0
  185. package/dist/publishing/event-publisher.sns.js +75 -0
  186. package/dist/publishing/event-publisher.stdout.d.ts +15 -0
  187. package/dist/publishing/event-publisher.stdout.js +24 -0
  188. package/dist/publishing/index.d.ts +10 -0
  189. package/dist/publishing/index.js +10 -0
  190. package/dist/publishing/layer.d.ts +25 -0
  191. package/dist/publishing/layer.js +39 -0
  192. package/dist/publishing/settings.d.ts +60 -0
  193. package/dist/publishing/settings.js +85 -0
  194. package/dist/queue/consumer.d.ts +91 -0
  195. package/dist/queue/consumer.js +92 -0
  196. package/dist/queue/envelope.d.ts +39 -0
  197. package/dist/queue/envelope.js +39 -0
  198. package/dist/queue/failures.d.ts +33 -0
  199. package/dist/queue/failures.js +15 -0
  200. package/dist/queue/index.d.ts +14 -0
  201. package/dist/queue/index.js +14 -0
  202. package/dist/queue/layer.d.ts +33 -0
  203. package/dist/queue/layer.js +58 -0
  204. package/dist/queue/message-codec.d.ts +43 -0
  205. package/dist/queue/message-codec.js +33 -0
  206. package/dist/queue/queue-message-producer.d.ts +61 -0
  207. package/dist/queue/queue-message-producer.js +52 -0
  208. package/dist/queue/queue-message-source.d.ts +93 -0
  209. package/dist/queue/queue-message-source.js +86 -0
  210. package/dist/queue/readiness.d.ts +78 -0
  211. package/dist/queue/readiness.js +86 -0
  212. package/dist/queue/settings.d.ts +106 -0
  213. package/dist/queue/settings.js +160 -0
  214. package/dist/storage/blob-store.d.ts +84 -0
  215. package/dist/storage/blob-store.js +109 -0
  216. package/dist/storage/index.d.ts +9 -0
  217. package/dist/storage/index.js +9 -0
  218. package/dist/storage/layer.d.ts +17 -0
  219. package/dist/storage/layer.js +30 -0
  220. package/dist/storage/settings.d.ts +48 -0
  221. package/dist/storage/settings.js +65 -0
  222. package/dist/testing/blob-store.d.ts +27 -0
  223. package/dist/testing/blob-store.js +45 -0
  224. package/dist/testing/clock.d.ts +29 -0
  225. package/dist/testing/clock.js +41 -0
  226. package/dist/testing/dna-perturbator/attention.d.ts +55 -0
  227. package/dist/testing/dna-perturbator/attention.js +49 -0
  228. package/dist/testing/dna-perturbator/chains.d.ts +31 -0
  229. package/dist/testing/dna-perturbator/chains.js +53 -0
  230. package/dist/testing/dna-perturbator/report.d.ts +12 -0
  231. package/dist/testing/dna-perturbator/report.js +22 -0
  232. package/dist/testing/dna-perturbator/verdict.d.ts +26 -0
  233. package/dist/testing/dna-perturbator/verdict.js +28 -0
  234. package/dist/testing/dna-perturbator.d.ts +48 -0
  235. package/dist/testing/dna-perturbator.js +145 -0
  236. package/dist/testing/document-number-counter.d.ts +29 -0
  237. package/dist/testing/document-number-counter.js +39 -0
  238. package/dist/testing/event-publisher.d.ts +28 -0
  239. package/dist/testing/event-publisher.js +33 -0
  240. package/dist/testing/external-http-client.d.ts +29 -0
  241. package/dist/testing/external-http-client.js +49 -0
  242. package/dist/testing/index.d.ts +24 -0
  243. package/dist/testing/index.js +24 -0
  244. package/dist/testing/integration-event-outbox.d.ts +37 -0
  245. package/dist/testing/integration-event-outbox.js +67 -0
  246. package/dist/testing/mutation-settings.d.ts +71 -0
  247. package/dist/testing/mutation-settings.js +57 -0
  248. package/dist/testing/postgres-rows.d.ts +12 -0
  249. package/dist/testing/postgres-rows.js +19 -0
  250. package/dist/testing/processed-event-registry.d.ts +25 -0
  251. package/dist/testing/processed-event-registry.js +39 -0
  252. package/dist/testing/queue-message-producer.d.ts +27 -0
  253. package/dist/testing/queue-message-producer.js +41 -0
  254. package/dist/testing/queue-message-source.d.ts +54 -0
  255. package/dist/testing/queue-message-source.js +71 -0
  256. package/dist/testing/quiet-reporter.d.ts +86 -0
  257. package/dist/testing/quiet-reporter.js +81 -0
  258. package/dist/testing/readiness-probe.d.ts +27 -0
  259. package/dist/testing/readiness-probe.js +33 -0
  260. package/dist/testing/scenario.d.ts +94 -0
  261. package/dist/testing/scenario.js +81 -0
  262. package/dist/testing/transaction-boundary.d.ts +30 -0
  263. package/dist/testing/transaction-boundary.js +36 -0
  264. package/dist/testing/typed-failure.d.ts +8 -0
  265. package/dist/testing/typed-failure.js +15 -0
  266. package/dist/time/calendar-day.d.ts +83 -0
  267. package/dist/time/calendar-day.js +112 -0
  268. package/dist/time/failures.d.ts +51 -0
  269. package/dist/time/failures.js +56 -0
  270. package/dist/time/index.d.ts +11 -0
  271. package/dist/time/index.js +11 -0
  272. package/dist/time/instant.d.ts +64 -0
  273. package/dist/time/instant.js +67 -0
  274. package/dist/time/period.d.ts +45 -0
  275. package/dist/time/period.js +55 -0
  276. package/dist/vitest/container-reuse.d.ts +14 -0
  277. package/dist/vitest/container-reuse.js +15 -0
  278. package/dist/vitest/dotenv.d.ts +16 -0
  279. package/dist/vitest/dotenv.js +48 -0
  280. package/dist/vitest/effect-single-instance.d.ts +36 -0
  281. package/dist/vitest/effect-single-instance.js +125 -0
  282. package/dist/vitest/fast-check-single-instance.d.ts +11 -0
  283. package/dist/vitest/fast-check-single-instance.js +26 -0
  284. package/dist/vitest/index.d.ts +31 -0
  285. package/dist/vitest/index.js +30 -0
  286. package/dist/vitest/localstack-container.d.ts +32 -0
  287. package/dist/vitest/localstack-container.js +55 -0
  288. package/dist/vitest/port-contract-tester.d.ts +8 -0
  289. package/dist/vitest/port-contract-tester.js +1 -0
  290. package/dist/vitest/port-contracts/blob-store.contract.d.ts +22 -0
  291. package/dist/vitest/port-contracts/blob-store.contract.js +108 -0
  292. package/dist/vitest/port-contracts/document-number-counter.contract.d.ts +18 -0
  293. package/dist/vitest/port-contracts/document-number-counter.contract.js +90 -0
  294. package/dist/vitest/port-contracts/event-publisher.contract.d.ts +35 -0
  295. package/dist/vitest/port-contracts/event-publisher.contract.js +67 -0
  296. package/dist/vitest/port-contracts/external-http-client.contract.d.ts +35 -0
  297. package/dist/vitest/port-contracts/external-http-client.contract.js +52 -0
  298. package/dist/vitest/port-contracts/integration-event-outbox.contract.d.ts +23 -0
  299. package/dist/vitest/port-contracts/integration-event-outbox.contract.js +75 -0
  300. package/dist/vitest/port-contracts/processed-event-registry.contract.d.ts +18 -0
  301. package/dist/vitest/port-contracts/processed-event-registry.contract.js +84 -0
  302. package/dist/vitest/port-contracts/queue-message-producer.contract.d.ts +26 -0
  303. package/dist/vitest/port-contracts/queue-message-producer.contract.js +48 -0
  304. package/dist/vitest/port-contracts/queue-message-source.contract.d.ts +29 -0
  305. package/dist/vitest/port-contracts/queue-message-source.contract.js +71 -0
  306. package/dist/vitest/port-contracts/readiness-probe.contract.d.ts +18 -0
  307. package/dist/vitest/port-contracts/readiness-probe.contract.js +41 -0
  308. package/dist/vitest/port-contracts/transaction-boundary.contract.d.ts +21 -0
  309. package/dist/vitest/port-contracts/transaction-boundary.contract.js +64 -0
  310. package/dist/vitest/postgres-container.d.ts +57 -0
  311. package/dist/vitest/postgres-container.js +118 -0
  312. package/dist/vitest/reader-port-contract.d.ts +41 -0
  313. package/dist/vitest/reader-port-contract.js +43 -0
  314. package/dist/vitest/repo-source-files.d.ts +37 -0
  315. package/dist/vitest/repo-source-files.js +61 -0
  316. package/dist/vitest/reset-tables.d.ts +18 -0
  317. package/dist/vitest/reset-tables.js +30 -0
  318. package/dist/vitest/settings-contract.d.ts +38 -0
  319. package/dist/vitest/settings-contract.js +77 -0
  320. package/dist/vitest/stub-server.d.ts +43 -0
  321. package/dist/vitest/stub-server.js +60 -0
  322. package/dist/vitest/term-registry.json +730 -0
  323. package/dist/vitest/testclock-fork-isolated-files.d.ts +13 -0
  324. package/dist/vitest/testclock-fork-isolated-files.js +18 -0
  325. package/dist/vitest/testclock-fork-isolation.d.ts +77 -0
  326. package/dist/vitest/testclock-fork-isolation.js +188 -0
  327. package/dist/workflow/claim-once.d.ts +16 -0
  328. package/dist/workflow/claim-once.js +9 -0
  329. package/dist/workflow/failures.d.ts +47 -0
  330. package/dist/workflow/failures.js +22 -0
  331. package/dist/workflow/index.d.ts +12 -0
  332. package/dist/workflow/index.js +12 -0
  333. package/dist/workflow/on-claim.d.ts +17 -0
  334. package/dist/workflow/on-claim.js +8 -0
  335. package/dist/workflow/publish-pending-events.d.ts +31 -0
  336. package/dist/workflow/publish-pending-events.js +31 -0
  337. package/dist/workflow/transactionally.d.ts +8 -0
  338. package/dist/workflow/transactionally.js +8 -0
  339. package/dist/workflow/versioned-payload.d.ts +35 -0
  340. package/dist/workflow/versioned-payload.js +43 -0
  341. package/package.json +309 -0
@@ -0,0 +1,64 @@
1
+ import { describe, expect } from '@effect/vitest';
2
+ import { TransactionBoundary } from '@satorio/machinery/ports';
3
+ import { Effect, Ref } from 'effect';
4
+ const WORK_REFUSED = 'the domain said no';
5
+ /**
6
+ * One contract, every transaction boundary. The same body runs against an
7
+ * in-memory boundary and a live Postgres transaction, so a difference
8
+ * between them shows up as a red test, not a surprise in production.
9
+ * Hand it the port's unavailable stand-in as a fourth argument and it
10
+ * walks the refusal too: the work never runs, and the failure reaches
11
+ * the caller.
12
+ *
13
+ * What this contract cannot prove: that what the work wrote is what gets
14
+ * rolled back. An in-memory boundary restores the `Ref`s it was handed as
15
+ * participants; a Postgres one rolls back a real transaction and knows
16
+ * nothing about `Ref`s. Both promises are held here through a `Ref` the
17
+ * work updates and the boundary never sees, so the assertions read the
18
+ * same on both sides — and the Postgres half, where a row written inside
19
+ * a failed transaction is gone afterwards, is owned by
20
+ * `src/postgres/ports.postgres.test.ts`.
21
+ */
22
+ export const transactionBoundaryContract = (it, implementationName, layer, unavailable) => {
23
+ const run = (effect) => Effect.provide(effect, layer);
24
+ describe(`TransactionBoundary contract (${implementationName})`, () => {
25
+ it.effect('work that succeeds answers with its own value', () => run(Effect.gen(function* () {
26
+ const boundary = yield* TransactionBoundary;
27
+ const answer = yield* boundary.inTransaction(Effect.succeed('committed'));
28
+ expect(answer).toBe('committed');
29
+ })));
30
+ it.effect("the work's own failure reaches the caller unchanged", () => run(Effect.gen(function* () {
31
+ const boundary = yield* TransactionBoundary;
32
+ const failure = yield* Effect.flip(boundary.inTransaction(Effect.fail(WORK_REFUSED)));
33
+ expect(failure).toBe(WORK_REFUSED);
34
+ })));
35
+ it.effect('every step of the work runs inside one boundary', () => run(Effect.gen(function* () {
36
+ const boundary = yield* TransactionBoundary;
37
+ const steps = yield* Ref.make([]);
38
+ yield* boundary.inTransaction(Effect.gen(function* () {
39
+ yield* Ref.update(steps, (taken) => [...taken, 'first']);
40
+ yield* Ref.update(steps, (taken) => [...taken, 'second']);
41
+ }));
42
+ expect(yield* Ref.get(steps)).toEqual(['first', 'second']);
43
+ })));
44
+ it.effect('one boundary nests inside another without deadlocking', () => run(Effect.gen(function* () {
45
+ const boundary = yield* TransactionBoundary;
46
+ const answer = yield* boundary.inTransaction(boundary.inTransaction(Effect.succeed('inner')));
47
+ expect(answer).toBe('inner');
48
+ })));
49
+ });
50
+ if (unavailable === undefined)
51
+ return;
52
+ describe(`TransactionBoundary contract (${implementationName}, not mounted)`, () => {
53
+ it.effect('the work never runs, and the caller reads the refusal', () => Effect.gen(function* () {
54
+ const boundary = yield* TransactionBoundary;
55
+ const ran = yield* Ref.make(false);
56
+ const failure = yield* Effect.flip(boundary.inTransaction(Ref.set(ran, true)));
57
+ expect(failure._tag).toBe('TransactionBoundaryUnavailableError');
58
+ if (failure._tag === 'TransactionBoundaryUnavailableError') {
59
+ expect(failure.operation).toBe('inTransaction');
60
+ }
61
+ expect(yield* Ref.get(ran)).toBe(false);
62
+ }).pipe(Effect.provide(unavailable)));
63
+ });
64
+ };
@@ -0,0 +1,57 @@
1
+ import { SqlClient } from '@effect/sql';
2
+ import type { PgClient } from '@effect/sql-pg';
3
+ import { type Vitest } from '@effect/vitest';
4
+ import { type PostgresPorts } from '@satorio/machinery/postgres';
5
+ import { Layer } from 'effect';
6
+ /**
7
+ * The Postgres image every live-stand test starts through testcontainers.
8
+ * This package ships no `docker-compose.yml` of its own, so nothing here
9
+ * proves this pin against a compose file. A consumer that also runs a
10
+ * local docker-compose Postgres (see `onPostgres`'s fallback path) MUST
11
+ * assert its own compose file's image equals this constant — that
12
+ * cross-check is the real guard, in that consumer's own
13
+ * `guards/postgres-image.test.ts`.
14
+ */
15
+ export declare const POSTGRES_IMAGE = "postgres:16-alpine";
16
+ type SuiteChecks = (it: Vitest.MethodsNonLive<never, true>) => void;
17
+ /**
18
+ * @internal
19
+ */
20
+ export interface PostgresSuiteOptions {
21
+ readonly schema: string;
22
+ readonly migrationDirectories?: readonly string[];
23
+ }
24
+ /**
25
+ * The Postgres layer a suite runs on: the configured database when the
26
+ * environment names one, a fresh container when it does not, migrated either
27
+ * way.
28
+ */
29
+ export declare const postgresUnderTestLive: (options: PostgresSuiteOptions) => Layer.Layer<never>;
30
+ /**
31
+ * Builds the Postgres layer for a suite from a fresh container only,
32
+ * ignoring any `PG*` environment variables. Use this instead of
33
+ * `postgresUnderTestLive` when a suite must prove itself against a real
34
+ * container, not whatever database happens to be configured.
35
+ */
36
+ export declare const postgresInContainerLive: (options: PostgresSuiteOptions) => Layer.Layer<never>;
37
+ /**
38
+ * Runs a suite against a real Postgres — the container, or an externally
39
+ * configured database when `PGHOST` is set. Pass `checks` to declare the
40
+ * tests.
41
+ */
42
+ export declare const onPostgres: (options: PostgresSuiteOptions, subject: string, checks: SuiteChecks) => void;
43
+ /**
44
+ * Runs a suite against a container-only Postgres, ignoring any `PG*`
45
+ * environment variables. Use this over `onPostgres` when a suite must
46
+ * prove the container path itself, not whichever database is configured.
47
+ * Pass `checks` to declare the tests.
48
+ */
49
+ export declare const onPostgresContainer: (options: PostgresSuiteOptions, subject: string, checks: SuiteChecks) => void;
50
+ /**
51
+ * The Postgres mechanism's own ports, on the suite's database. A failure to
52
+ * mount the database client folds into a defect rather than a typed error,
53
+ * since inside a suite that is a broken harness, not a scenario the test
54
+ * should handle.
55
+ */
56
+ export declare const PostgresPortsUnderTestLive: Layer.Layer<PostgresPorts | PgClient.PgClient | SqlClient.SqlClient>;
57
+ export {};
@@ -0,0 +1,118 @@
1
+ import { BunFileSystem } from '@effect/platform-bun';
2
+ import { SqlClient } from '@effect/sql';
3
+ import { layer } from '@effect/vitest';
4
+ import { applyMigrations, DEFAULT_POSTGRES_PORT, MACHINERY_MIGRATIONS_DIRECTORY, PostgresClientLive, PostgresPortsOnBunLive, postgresConnectionSettings, } from '@satorio/machinery/postgres';
5
+ import { PostgreSqlContainer } from '@testcontainers/postgresql';
6
+ import { Config, ConfigProvider, Duration, Effect, Layer, Logger, Option, } from 'effect';
7
+ import { Wait } from 'testcontainers';
8
+ import { standsMayBeReused } from './container-reuse.js';
9
+ /**
10
+ * The Postgres image every live-stand test starts through testcontainers.
11
+ * This package ships no `docker-compose.yml` of its own, so nothing here
12
+ * proves this pin against a compose file. A consumer that also runs a
13
+ * local docker-compose Postgres (see `onPostgres`'s fallback path) MUST
14
+ * assert its own compose file's image equals this constant — that
15
+ * cross-check is the real guard, in that consumer's own
16
+ * `guards/postgres-image.test.ts`.
17
+ */
18
+ export const POSTGRES_IMAGE = 'postgres:16-alpine';
19
+ const CONTAINER_DATABASE = 'app';
20
+ const CONTAINER_USER = 'app';
21
+ const CONTAINER_PASSWORD = 'app';
22
+ const SUITE_STARTUP_BUDGET = Duration.minutes(3);
23
+ const POSTGRES_READY_LOG_MESSAGE = /database system is ready to accept connections/;
24
+ const REUSING_STANDS = standsMayBeReused();
25
+ const startedContainer = Effect.acquireRelease(Effect.promise(() => {
26
+ const container = new PostgreSqlContainer(POSTGRES_IMAGE)
27
+ .withDatabase(CONTAINER_DATABASE)
28
+ .withUsername(CONTAINER_USER)
29
+ .withPassword(CONTAINER_PASSWORD)
30
+ .withWaitStrategy(Wait.forLogMessage(POSTGRES_READY_LOG_MESSAGE, 2));
31
+ return (REUSING_STANDS ? container.withReuse() : container).start();
32
+ }), (container) => REUSING_STANDS ? Effect.void : Effect.promise(() => container.stop()));
33
+ const externallyConfiguredDatabase = Config.all({
34
+ host: Config.string('PGHOST'),
35
+ port: Config.integer('PGPORT').pipe(Config.withDefault(DEFAULT_POSTGRES_PORT)),
36
+ user: Config.string('PGUSER'),
37
+ password: Config.string('PGPASSWORD'),
38
+ database: Config.string('PGDATABASE'),
39
+ });
40
+ const containerDatabase = Effect.map(startedContainer, (container) => ({
41
+ host: container.getHost(),
42
+ port: container.getPort(),
43
+ user: container.getUsername(),
44
+ password: container.getPassword(),
45
+ database: container.getDatabase(),
46
+ }));
47
+ const databaseUnderTest = Effect.flatMap(Config.option(externallyConfiguredDatabase), Option.match({
48
+ onSome: (Effect.succeed),
49
+ onNone: () => containerDatabase,
50
+ }));
51
+ const connectionSettingsOf = (database, schema) => ConfigProvider.fromMap(new Map([
52
+ ['PGHOST', database.host],
53
+ ['PGPORT', String(database.port)],
54
+ ['PGUSER', database.user],
55
+ ['PGPASSWORD', database.password],
56
+ ['PGDATABASE', database.database],
57
+ ['PGSCHEMA', schema],
58
+ ['AUTH_MODE', 'password'],
59
+ ['PGSSLMODE', 'disable'],
60
+ ]));
61
+ const MIGRATOR_TRACKING_TABLE = 'effect_sql_migrations';
62
+ const truncateEverythingIn = (schema) => Effect.gen(function* () {
63
+ const sql = yield* SqlClient.SqlClient;
64
+ const tables = yield* sql `SELECT tablename FROM pg_tables
65
+ WHERE schemaname = ${schema} AND tablename != ${MIGRATOR_TRACKING_TABLE}`;
66
+ if (tables.length === 0)
67
+ return;
68
+ const quoted = tables
69
+ .map((table) => `"${schema}"."${table.tablename}"`)
70
+ .join(', ');
71
+ yield* sql.unsafe(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
72
+ });
73
+ const migratedSchema = (migrationDirectories) => Layer.effectDiscard(Effect.gen(function* () {
74
+ const sql = yield* SqlClient.SqlClient;
75
+ const settings = yield* postgresConnectionSettings;
76
+ yield* sql.unsafe(`CREATE SCHEMA IF NOT EXISTS "${settings.schema}"`);
77
+ yield* applyMigrations(migrationDirectories);
78
+ yield* truncateEverythingIn(settings.schema);
79
+ })).pipe(Layer.provide(PostgresClientLive), Layer.provide(BunFileSystem.layer), Layer.orDie);
80
+ const migratedIn = (database, schema, migrationDirectories) => Layer.unwrapScoped(Effect.map(database, (connection) => migratedSchema(migrationDirectories).pipe(Layer.provideMerge(Layer.setConfigProvider(connectionSettingsOf(connection, schema))), Layer.merge(Logger.remove(Logger.defaultLogger))))).pipe(Layer.orDie);
81
+ const directoriesOf = (options) => options.migrationDirectories ?? [MACHINERY_MIGRATIONS_DIRECTORY];
82
+ /**
83
+ * The Postgres layer a suite runs on: the configured database when the
84
+ * environment names one, a fresh container when it does not, migrated either
85
+ * way.
86
+ */
87
+ export const postgresUnderTestLive = (options) => migratedIn(databaseUnderTest, options.schema, directoriesOf(options));
88
+ /**
89
+ * Builds the Postgres layer for a suite from a fresh container only,
90
+ * ignoring any `PG*` environment variables. Use this instead of
91
+ * `postgresUnderTestLive` when a suite must prove itself against a real
92
+ * container, not whatever database happens to be configured.
93
+ */
94
+ export const postgresInContainerLive = (options) => migratedIn(containerDatabase, options.schema, directoriesOf(options));
95
+ const suiteOn = (postgres) => (subject, checks) => layer(postgres, {
96
+ excludeTestServices: true,
97
+ timeout: SUITE_STARTUP_BUDGET,
98
+ })(subject, checks);
99
+ /**
100
+ * Runs a suite against a real Postgres — the container, or an externally
101
+ * configured database when `PGHOST` is set. Pass `checks` to declare the
102
+ * tests.
103
+ */
104
+ export const onPostgres = (options, subject, checks) => suiteOn(postgresUnderTestLive(options))(subject, checks);
105
+ /**
106
+ * Runs a suite against a container-only Postgres, ignoring any `PG*`
107
+ * environment variables. Use this over `onPostgres` when a suite must
108
+ * prove the container path itself, not whichever database is configured.
109
+ * Pass `checks` to declare the tests.
110
+ */
111
+ export const onPostgresContainer = (options, subject, checks) => suiteOn(postgresInContainerLive(options))(subject, checks);
112
+ /**
113
+ * The Postgres mechanism's own ports, on the suite's database. A failure to
114
+ * mount the database client folds into a defect rather than a typed error,
115
+ * since inside a suite that is a broken harness, not a scenario the test
116
+ * should handle.
117
+ */
118
+ export const PostgresPortsUnderTestLive = PostgresPortsOnBunLive.pipe(Layer.orDie);
@@ -0,0 +1,41 @@
1
+ import { Effect } from 'effect';
2
+ import type { PortContractTester } from './port-contract-tester.js';
3
+ /**
4
+ * What a reader port's own test hands the contract. `read` and
5
+ * `readWhenUnavailable` come already provided with their layer, so the
6
+ * contract needs no context type and every implementation hands it the
7
+ * same shape. `givenTheWorldHolds` is the seam: a reader port never
8
+ * writes, so whatever put the value there — a seeder, a migration, a
9
+ * fixture — belongs to the implementation's own test, not to the port.
10
+ */
11
+ export interface ReaderPortUnderTest<Subject, Value> {
12
+ readonly portName: string;
13
+ readonly implementationName: string;
14
+ readonly known: {
15
+ readonly subject: Subject;
16
+ readonly value: Value;
17
+ };
18
+ readonly unknown: Subject;
19
+ readonly givenTheWorldHolds: (subject: Subject, value: Value) => Effect.Effect<unknown>;
20
+ readonly read: (subject: Subject) => Effect.Effect<Value, {
21
+ readonly _tag: string;
22
+ }>;
23
+ readonly refusalWhenUnknown: string;
24
+ readonly readWhenUnavailable?: (subject: Subject) => Effect.Effect<Value, {
25
+ readonly _tag: string;
26
+ }>;
27
+ readonly refusalWhenUnavailable?: string;
28
+ }
29
+ /**
30
+ * One contract for a port that only reads. It asks the port for nothing
31
+ * it cannot do: no write, no round trip through its own writer. A
32
+ * projection, a view of another context, a rate source, a table somebody
33
+ * else fills — each passes this contract with a write side it does not
34
+ * have.
35
+ *
36
+ * What this contract cannot prove: that the world outside really holds
37
+ * what `givenTheWorldHolds` put there. A fake seeds its own map; a live
38
+ * implementation seeds the real store, and only that run proves the two
39
+ * agree on the shape of a row.
40
+ */
41
+ export declare const readerPortContract: <Subject, Value>(it: PortContractTester, port: ReaderPortUnderTest<Subject, Value>) => void;
@@ -0,0 +1,43 @@
1
+ import { describe, expect } from '@effect/vitest';
2
+ import { Effect } from 'effect';
3
+ /**
4
+ * One contract for a port that only reads. It asks the port for nothing
5
+ * it cannot do: no write, no round trip through its own writer. A
6
+ * projection, a view of another context, a rate source, a table somebody
7
+ * else fills — each passes this contract with a write side it does not
8
+ * have.
9
+ *
10
+ * What this contract cannot prove: that the world outside really holds
11
+ * what `givenTheWorldHolds` put there. A fake seeds its own map; a live
12
+ * implementation seeds the real store, and only that run proves the two
13
+ * agree on the shape of a row.
14
+ */
15
+ export const readerPortContract = (it, port) => {
16
+ describe(`${port.portName} contract (${port.implementationName})`, () => {
17
+ it.effect('a subject the world holds reads back what it holds', () => Effect.gen(function* () {
18
+ yield* port.givenTheWorldHolds(port.known.subject, port.known.value);
19
+ const read = yield* port.read(port.known.subject);
20
+ expect(read).toEqual(port.known.value);
21
+ }));
22
+ it.effect('reading twice answers twice, and holds nothing back', () => Effect.gen(function* () {
23
+ yield* port.givenTheWorldHolds(port.known.subject, port.known.value);
24
+ const first = yield* port.read(port.known.subject);
25
+ const second = yield* port.read(port.known.subject);
26
+ expect(second).toEqual(first);
27
+ }));
28
+ it.effect('a subject the world does not hold refuses by name', () => Effect.gen(function* () {
29
+ const refused = yield* Effect.flip(port.read(port.unknown));
30
+ expect(refused._tag).toBe(port.refusalWhenUnknown);
31
+ }));
32
+ });
33
+ const readWhenUnavailable = port.readWhenUnavailable;
34
+ const refusalWhenUnavailable = port.refusalWhenUnavailable;
35
+ if (readWhenUnavailable === undefined || refusalWhenUnavailable === undefined)
36
+ return;
37
+ describe(`${port.portName} contract (${port.implementationName}, not mounted)`, () => {
38
+ it.effect('a port nobody mounted refuses, and the caller reads it', () => Effect.gen(function* () {
39
+ const refused = yield* Effect.flip(readWhenUnavailable(port.known.subject));
40
+ expect(refused._tag).toBe(refusalWhenUnavailable);
41
+ }));
42
+ });
43
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Runs one git command in `cwd` with every `GIT_*` variable removed from
3
+ * its environment, and hands back what it printed. A hook runs with
4
+ * `GIT_DIR` and `GIT_INDEX_FILE` exported, and git obeys those over the
5
+ * directory it was given: a read would answer for the hook's repository
6
+ * instead of `cwd`, and a write would land in the commit that is running.
7
+ * Every git call this package makes goes through here for that reason.
8
+ *
9
+ * @internal
10
+ */
11
+ export declare const gitOutput: (args: readonly string[], cwd: string) => string;
12
+ /**
13
+ * Root of the git repository that contains `cwd` (`process.cwd()` by
14
+ * default). Every guard resolves its own scan root through this call, so
15
+ * the same guard function scans the package's own tree when the package
16
+ * runs its own tests, and a consumer's tree when a consumer's test file
17
+ * calls the guard.
18
+ */
19
+ export declare const repoRoot: (cwd?: string) => string;
20
+ /**
21
+ * Every file git would commit as it stands under `root`.
22
+ *
23
+ * Every file `git` would commit as-is under `root`: tracked files, plus
24
+ * untracked files that are not `.gitignore`d, minus tracked files deleted from
25
+ * the working tree but not yet staged as deleted. A guard that must hold
26
+ * before a file is ever committed needs this set — `git ls-files` alone misses
27
+ * a file still untracked, a plain filesystem walk would wrongly pull in
28
+ * `.gitignore`d local files such as `.env`, and skipping the deleted-file
29
+ * subtraction hands a guard a path that no longer exists.
30
+ */
31
+ export declare const repoCommittableFiles: (root?: string) => readonly string[];
32
+ /**
33
+ * Lists files with `extension` recursively under `directories`, relative
34
+ * to `root`. Directories are relative to `root`, and so is the result:
35
+ * relative POSIX paths, the format every guard and file list uses.
36
+ */
37
+ export declare const repoFilesUnder: (root: string, directories: readonly string[], extension: string) => readonly string[];
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readdirSync } from 'node:fs';
3
+ import { extname, join, relative, sep } from 'node:path';
4
+ const withoutAmbientGit = () => Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
5
+ /**
6
+ * Runs one git command in `cwd` with every `GIT_*` variable removed from
7
+ * its environment, and hands back what it printed. A hook runs with
8
+ * `GIT_DIR` and `GIT_INDEX_FILE` exported, and git obeys those over the
9
+ * directory it was given: a read would answer for the hook's repository
10
+ * instead of `cwd`, and a write would land in the commit that is running.
11
+ * Every git call this package makes goes through here for that reason.
12
+ *
13
+ * @internal
14
+ */
15
+ export const gitOutput = (args, cwd) => execFileSync('git', [...args], {
16
+ cwd,
17
+ encoding: 'utf8',
18
+ env: withoutAmbientGit(),
19
+ });
20
+ /**
21
+ * Root of the git repository that contains `cwd` (`process.cwd()` by
22
+ * default). Every guard resolves its own scan root through this call, so
23
+ * the same guard function scans the package's own tree when the package
24
+ * runs its own tests, and a consumer's tree when a consumer's test file
25
+ * calls the guard.
26
+ */
27
+ export const repoRoot = (cwd = process.cwd()) => gitOutput(['rev-parse', '--show-toplevel'], cwd).trim();
28
+ const toPosixPath = (path) => path.split(sep).join('/');
29
+ const walk = (root, directory, extension) => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
30
+ const path = join(directory, entry.name);
31
+ if (entry.isDirectory())
32
+ return walk(root, path, extension);
33
+ return entry.isFile() && extname(entry.name) === extension
34
+ ? [toPosixPath(relative(root, path))]
35
+ : [];
36
+ });
37
+ /**
38
+ * Every file git would commit as it stands under `root`.
39
+ *
40
+ * Every file `git` would commit as-is under `root`: tracked files, plus
41
+ * untracked files that are not `.gitignore`d, minus tracked files deleted from
42
+ * the working tree but not yet staged as deleted. A guard that must hold
43
+ * before a file is ever committed needs this set — `git ls-files` alone misses
44
+ * a file still untracked, a plain filesystem walk would wrongly pull in
45
+ * `.gitignore`d local files such as `.env`, and skipping the deleted-file
46
+ * subtraction hands a guard a path that no longer exists.
47
+ */
48
+ export const repoCommittableFiles = (root = repoRoot()) => {
49
+ const deletedFiles = new Set(gitOutput(['ls-files', '--deleted'], root)
50
+ .split('\n')
51
+ .filter((line) => line !== ''));
52
+ return gitOutput(['ls-files', '--cached', '--others', '--exclude-standard'], root)
53
+ .split('\n')
54
+ .filter((line) => line !== '' && !deletedFiles.has(line));
55
+ };
56
+ /**
57
+ * Lists files with `extension` recursively under `directories`, relative
58
+ * to `root`. Directories are relative to `root`, and so is the result:
59
+ * relative POSIX paths, the format every guard and file list uses.
60
+ */
61
+ export const repoFilesUnder = (root, directories, extension) => directories.flatMap((directory) => walk(root, join(root, directory), extension));
@@ -0,0 +1,18 @@
1
+ import { SqlClient } from '@effect/sql';
2
+ import type { SqlError } from '@effect/sql/SqlError';
3
+ import { Effect } from 'effect';
4
+ /**
5
+ * Empties the named tables in one query, for a test that needs a familiar
6
+ * initial state.
7
+ *
8
+ * Empties `tableNames` in one query — for a test that needs a FAMILIAR initial
9
+ * state, not just its own unique key, typically a race test where the key is
10
+ * deliberately shared across several parallel calls. This is an exception, not
11
+ * a habit: the rest of the suite keeps tests independent through different
12
+ * keys per test (see `stock-ledger-store.contract.ts`), because a TRUNCATE
13
+ * between every test would serialize parallel test files on a table lock.
14
+ * Table names go straight into raw SQL, so the format check only guards
15
+ * against a stray quote or space in a call-site literal escaping the quoting —
16
+ * a test is not hostile input, so this is not a defense against an attacker.
17
+ */
18
+ export declare const truncateTables: (tableNames: readonly string[]) => Effect.Effect<void, SqlError, SqlClient.SqlClient>;
@@ -0,0 +1,30 @@
1
+ import { SqlClient } from '@effect/sql';
2
+ import { Effect } from 'effect';
3
+ const TABLE_IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_]*$/;
4
+ /**
5
+ * Empties the named tables in one query, for a test that needs a familiar
6
+ * initial state.
7
+ *
8
+ * Empties `tableNames` in one query — for a test that needs a FAMILIAR initial
9
+ * state, not just its own unique key, typically a race test where the key is
10
+ * deliberately shared across several parallel calls. This is an exception, not
11
+ * a habit: the rest of the suite keeps tests independent through different
12
+ * keys per test (see `stock-ledger-store.contract.ts`), because a TRUNCATE
13
+ * between every test would serialize parallel test files on a table lock.
14
+ * Table names go straight into raw SQL, so the format check only guards
15
+ * against a stray quote or space in a call-site literal escaping the quoting —
16
+ * a test is not hostile input, so this is not a defense against an attacker.
17
+ */
18
+ export const truncateTables = (tableNames) => Effect.gen(function* () {
19
+ if (tableNames.length === 0)
20
+ return;
21
+ for (const name of tableNames) {
22
+ if (!TABLE_IDENTIFIER_PATTERN.test(name)) {
23
+ return yield* Effect.dieMessage(`truncateTables received a name outside the identifier format: ` +
24
+ `"${name}". Only call-site SQL literals belong here.`);
25
+ }
26
+ }
27
+ const sql = yield* SqlClient.SqlClient;
28
+ const quoted = tableNames.map((name) => `"${name}"`).join(', ');
29
+ yield* sql.unsafe(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
30
+ });
@@ -0,0 +1,38 @@
1
+ import { type EnvironmentEntry } from '@satorio/machinery/core';
2
+ import { type Config } from 'effect';
3
+ /**
4
+ * The count this contract stands on: how many variables one call was
5
+ * handed, and how many of them refuse. A contract reads no corpus of
6
+ * files, so it takes no `minimum*` of its own — the list its caller
7
+ * hands it is the whole of what it holds, and the count is printed every
8
+ * run so nobody has to guess how much a green suite covered.
9
+ *
10
+ * @internal
11
+ */
12
+ export declare const contractLineOf: (mechanism: string, environment: readonly EnvironmentEntry[]) => string;
13
+ /**
14
+ * What an empty list leaves unheld, said in the words the reader of a
15
+ * red run needs.
16
+ *
17
+ * @internal
18
+ */
19
+ export declare const emptyEnvironmentDetail: (mechanism: string) => string;
20
+ /**
21
+ * One contract, every adapter that names variables. It reads the whole
22
+ * settings against an **empty** environment and asserts, word for word,
23
+ * that each of the three parts of every `refuses` entry reaches the
24
+ * reader — the sentence that says what happened, the one that says why,
25
+ * and the one that says what to do. A refusal nobody asserts is a
26
+ * refusal the next edit rewrites while every test stays green.
27
+ *
28
+ * Call it once per adapter, next to that adapter's own tests, with the
29
+ * `ENVIRONMENT` it exports and the settings it builds. A mechanism whose
30
+ * variables carry a mount-time prefix passes that prefix too, and the
31
+ * contract puts it where `<PREFIX>` stands in the entry's text.
32
+ *
33
+ * What this contract cannot prove: that the refusal is the one a reader
34
+ * needed. It holds the three parts to the letter and says nothing about
35
+ * whether the third part names a step somebody can take — that is read,
36
+ * not checked.
37
+ */
38
+ export declare const settingsContract: <A>(mechanism: string, environment: readonly EnvironmentEntry[], settings: Config.Config<A>, variablePrefix?: string) => void;
@@ -0,0 +1,77 @@
1
+ import { refusalText, variableNameOf, } from '@satorio/machinery/core';
2
+ import { ConfigProvider, Effect } from 'effect';
3
+ import { describe, expect, it } from 'vitest';
4
+ const readAgainstNothing = (settings) => Effect.runPromise(Effect.either(Effect.withConfigProvider(settings, ConfigProvider.fromMap(new Map()))));
5
+ const PREFIX = '<PREFIX>';
6
+ const refusingIn = (environment) => environment.filter((entry) => entry.state.state === 'refuses');
7
+ /**
8
+ * The count this contract stands on: how many variables one call was
9
+ * handed, and how many of them refuse. A contract reads no corpus of
10
+ * files, so it takes no `minimum*` of its own — the list its caller
11
+ * hands it is the whole of what it holds, and the count is printed every
12
+ * run so nobody has to guess how much a green suite covered.
13
+ *
14
+ * @internal
15
+ */
16
+ export const contractLineOf = (mechanism, environment) => `${mechanism} settings contract: read ${environment.length} variable(s), ` +
17
+ `${refusingIn(environment).length} of them refusing`;
18
+ /**
19
+ * What an empty list leaves unheld, said in the words the reader of a
20
+ * red run needs.
21
+ *
22
+ * @internal
23
+ */
24
+ export const emptyEnvironmentDetail = (mechanism) => `${mechanism} handed the settings contract no variables at all. A contract ` +
25
+ 'over an empty list asserts nothing and stays green forever.';
26
+ const textOf = (error) => error instanceof Error ? error.message : String(error);
27
+ /**
28
+ * One contract, every adapter that names variables. It reads the whole
29
+ * settings against an **empty** environment and asserts, word for word,
30
+ * that each of the three parts of every `refuses` entry reaches the
31
+ * reader — the sentence that says what happened, the one that says why,
32
+ * and the one that says what to do. A refusal nobody asserts is a
33
+ * refusal the next edit rewrites while every test stays green.
34
+ *
35
+ * Call it once per adapter, next to that adapter's own tests, with the
36
+ * `ENVIRONMENT` it exports and the settings it builds. A mechanism whose
37
+ * variables carry a mount-time prefix passes that prefix too, and the
38
+ * contract puts it where `<PREFIX>` stands in the entry's text.
39
+ *
40
+ * What this contract cannot prove: that the refusal is the one a reader
41
+ * needed. It holds the three parts to the letter and says nothing about
42
+ * whether the third part names a step somebody can take — that is read,
43
+ * not checked.
44
+ */
45
+ export const settingsContract = (mechanism, environment, settings, variablePrefix = '') => {
46
+ const refusing = refusingIn(environment);
47
+ const under = (text) => variablePrefix === '' ? text : text.replaceAll(PREFIX, variablePrefix);
48
+ describe(`${mechanism} settings: what an empty environment answers`, () => {
49
+ it('was handed at least one variable to hold', () => {
50
+ console.log(contractLineOf(mechanism, environment));
51
+ expect(environment.length, emptyEnvironmentDetail(mechanism)).toBeGreaterThan(0);
52
+ });
53
+ it('every entry says what its variable is for', () => {
54
+ const silent = environment
55
+ .filter((entry) => entry.what.trim() === '')
56
+ .map(variableNameOf);
57
+ expect(silent, silent.join(', ')).toEqual([]);
58
+ });
59
+ it(`refuses on ${refusing.length} unset variable(s), or reads clean`, async () => {
60
+ const read = await readAgainstNothing(settings);
61
+ expect(read._tag).toBe(refusing.length === 0 ? 'Right' : 'Left');
62
+ });
63
+ for (const entry of refusing) {
64
+ const state = entry.state;
65
+ if (state.state !== 'refuses')
66
+ continue;
67
+ it(`${under(variableNameOf(entry))} refuses in three parts when unset`, async () => {
68
+ const read = await readAgainstNothing(settings);
69
+ const answered = read._tag === 'Left' ? textOf(read.left) : '';
70
+ expect(answered).toContain(under(state.refusal.what));
71
+ expect(answered).toContain(under(state.refusal.why));
72
+ expect(answered).toContain(under(state.refusal.whatToDo));
73
+ expect(answered).toContain(under(refusalText(state.refusal)));
74
+ });
75
+ }
76
+ });
77
+ };
@@ -0,0 +1,43 @@
1
+ import { Effect, type Scope } from 'effect';
2
+ /**
3
+ * One answer the stub server gives: the status, the body and content type
4
+ * where there is one, and the delay before it answers.
5
+ */
6
+ export interface StubResponse {
7
+ readonly status: number;
8
+ readonly body?: string;
9
+ readonly contentType?: string;
10
+ readonly delayMillis?: number;
11
+ }
12
+ /**
13
+ * One route the stub server serves: the method, the path, and the answers it
14
+ * gives in turn.
15
+ */
16
+ export interface StubRoute {
17
+ readonly method: string;
18
+ readonly path: string;
19
+ readonly responses: readonly StubResponse[];
20
+ }
21
+ /**
22
+ * One request the stub server received, as a test reads it back: the method
23
+ * and the path.
24
+ */
25
+ export interface RecordedStubRequest {
26
+ readonly method: string;
27
+ readonly path: string;
28
+ }
29
+ /**
30
+ * @internal
31
+ */
32
+ export interface StubServerShape {
33
+ readonly baseUrl: string;
34
+ readonly requestsReceived: () => readonly RecordedStubRequest[];
35
+ }
36
+ /**
37
+ * Runs the stub server for the lifetime of a scope.
38
+ *
39
+ * Runs the stub server for the lifetime of a Scope: starts on acquire, stops
40
+ * on release — the same lifecycle as the localstack and Postgres containers in
41
+ * this mechanism's other files.
42
+ */
43
+ export declare const scopedStubServer: (routes: readonly StubRoute[]) => Effect.Effect<StubServerShape, never, Scope.Scope>;