@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.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dictionary/all.json +4656 -0
- package/dictionary/all.toon +639 -0
- package/dictionary/core.json +221 -0
- package/dictionary/core.toon +33 -0
- package/dictionary/graphql.json +212 -0
- package/dictionary/graphql.toon +32 -0
- package/dictionary/http.json +113 -0
- package/dictionary/http.toon +21 -0
- package/dictionary/identifiers.json +266 -0
- package/dictionary/identifiers.toon +38 -0
- package/dictionary/index.json +96 -0
- package/dictionary/index.toon +18 -0
- package/dictionary/integration.json +131 -0
- package/dictionary/integration.toon +23 -0
- package/dictionary/money.json +383 -0
- package/dictionary/money.toon +51 -0
- package/dictionary/ports.json +392 -0
- package/dictionary/ports.toon +52 -0
- package/dictionary/postgres.json +473 -0
- package/dictionary/postgres.toon +61 -0
- package/dictionary/publishing.json +131 -0
- package/dictionary/publishing.toon +23 -0
- package/dictionary/queue.json +365 -0
- package/dictionary/queue.toon +49 -0
- package/dictionary/reference/api.mdx +843 -0
- package/dictionary/reference/uk/api.mdx +844 -0
- package/dictionary/storage.json +194 -0
- package/dictionary/storage.toon +30 -0
- package/dictionary/testing.json +707 -0
- package/dictionary/testing.toon +87 -0
- package/dictionary/time.json +311 -0
- package/dictionary/time.toon +43 -0
- package/dictionary/vitest.json +356 -0
- package/dictionary/vitest.toon +48 -0
- package/dictionary/workflow.json +185 -0
- package/dictionary/workflow.toon +29 -0
- package/dist/bin/command.d.ts +45 -0
- package/dist/bin/command.js +113 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +11 -0
- package/dist/core/bigint-json.d.ts +6 -0
- package/dist/core/bigint-json.js +6 -0
- package/dist/core/closed-set.d.ts +14 -0
- package/dist/core/closed-set.js +8 -0
- package/dist/core/domain-error.d.ts +8 -0
- package/dist/core/domain-error.js +11 -0
- package/dist/core/environment.d.ts +124 -0
- package/dist/core/environment.js +115 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/ulid.d.ts +27 -0
- package/dist/core/ulid.js +27 -0
- package/dist/graphql/builder.d.ts +36 -0
- package/dist/graphql/builder.js +35 -0
- package/dist/graphql/context.d.ts +36 -0
- package/dist/graphql/context.js +69 -0
- package/dist/graphql/failure-classification.d.ts +11 -0
- package/dist/graphql/failure-classification.js +1 -0
- package/dist/graphql/failures.d.ts +62 -0
- package/dist/graphql/failures.js +30 -0
- package/dist/graphql/graphiql.d.ts +45 -0
- package/dist/graphql/graphiql.js +56 -0
- package/dist/graphql/index.d.ts +15 -0
- package/dist/graphql/index.js +14 -0
- package/dist/graphql/layer.d.ts +15 -0
- package/dist/graphql/layer.js +23 -0
- package/dist/graphql/request-handler.d.ts +26 -0
- package/dist/graphql/request-handler.js +33 -0
- package/dist/graphql/server.d.ts +19 -0
- package/dist/graphql/server.js +16 -0
- package/dist/graphql/settings.d.ts +55 -0
- package/dist/graphql/settings.js +82 -0
- package/dist/http/health.d.ts +70 -0
- package/dist/http/health.js +60 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.js +5 -0
- package/dist/identifiers/client-code.d.ts +37 -0
- package/dist/identifiers/client-code.js +41 -0
- package/dist/identifiers/creditor-reference.d.ts +38 -0
- package/dist/identifiers/creditor-reference.js +32 -0
- package/dist/identifiers/damm.d.ts +22 -0
- package/dist/identifiers/damm.js +50 -0
- package/dist/identifiers/document-number.d.ts +68 -0
- package/dist/identifiers/document-number.js +73 -0
- package/dist/identifiers/document-numbers.d.ts +27 -0
- package/dist/identifiers/document-numbers.js +16 -0
- package/dist/identifiers/failures.d.ts +46 -0
- package/dist/identifiers/failures.js +48 -0
- package/dist/identifiers/index.d.ts +14 -0
- package/dist/identifiers/index.js +13 -0
- package/dist/identifiers/next-document-number.d.ts +26 -0
- package/dist/identifiers/next-document-number.js +31 -0
- package/dist/identifiers/ulid.d.ts +28 -0
- package/dist/identifiers/ulid.js +71 -0
- package/dist/integration/external-http-client.d.ts +22 -0
- package/dist/integration/external-http-client.js +74 -0
- package/dist/integration/index.d.ts +12 -0
- package/dist/integration/index.js +12 -0
- package/dist/integration/layer.d.ts +19 -0
- package/dist/integration/layer.js +30 -0
- package/dist/integration/retry.d.ts +65 -0
- package/dist/integration/retry.js +54 -0
- package/dist/integration/settings.d.ts +73 -0
- package/dist/integration/settings.js +92 -0
- package/dist/money/amount.d.ts +60 -0
- package/dist/money/amount.js +67 -0
- package/dist/money/arithmetic.d.ts +60 -0
- package/dist/money/arithmetic.js +73 -0
- package/dist/money/currency.d.ts +25 -0
- package/dist/money/currency.js +24 -0
- package/dist/money/decimal.d.ts +29 -0
- package/dist/money/decimal.js +39 -0
- package/dist/money/exchange-rate.d.ts +32 -0
- package/dist/money/exchange-rate.js +28 -0
- package/dist/money/failures.d.ts +86 -0
- package/dist/money/failures.js +97 -0
- package/dist/money/index.d.ts +15 -0
- package/dist/money/index.js +15 -0
- package/dist/money/percent.d.ts +35 -0
- package/dist/money/percent.js +50 -0
- package/dist/money/schema.d.ts +25 -0
- package/dist/money/schema.js +41 -0
- package/dist/ports/blob-store.d.ts +42 -0
- package/dist/ports/blob-store.js +13 -0
- package/dist/ports/document-number-counter.d.ts +36 -0
- package/dist/ports/document-number-counter.js +12 -0
- package/dist/ports/event-publisher.d.ts +29 -0
- package/dist/ports/event-publisher.js +13 -0
- package/dist/ports/external-http-client.d.ts +60 -0
- package/dist/ports/external-http-client.js +25 -0
- package/dist/ports/index.d.ts +17 -0
- package/dist/ports/index.js +17 -0
- package/dist/ports/integration-event-outbox.d.ts +44 -0
- package/dist/ports/integration-event-outbox.js +13 -0
- package/dist/ports/port-unavailable.d.ts +21 -0
- package/dist/ports/port-unavailable.js +30 -0
- package/dist/ports/processed-event-registry.d.ts +57 -0
- package/dist/ports/processed-event-registry.js +24 -0
- package/dist/ports/queue-message-producer.d.ts +26 -0
- package/dist/ports/queue-message-producer.js +12 -0
- package/dist/ports/queue-message-source.d.ts +39 -0
- package/dist/ports/queue-message-source.js +13 -0
- package/dist/ports/readiness-probe.d.ts +27 -0
- package/dist/ports/readiness-probe.js +13 -0
- package/dist/ports/transaction-boundary.d.ts +28 -0
- package/dist/ports/transaction-boundary.js +13 -0
- package/dist/postgres/client.d.ts +51 -0
- package/dist/postgres/client.js +81 -0
- package/dist/postgres/document-number-counter.d.ts +14 -0
- package/dist/postgres/document-number-counter.js +27 -0
- package/dist/postgres/failures.d.ts +28 -0
- package/dist/postgres/failures.js +18 -0
- package/dist/postgres/index.d.ts +18 -0
- package/dist/postgres/index.js +18 -0
- package/dist/postgres/integration-event-outbox.d.ts +15 -0
- package/dist/postgres/integration-event-outbox.js +60 -0
- package/dist/postgres/jsonb-value.d.ts +10 -0
- package/dist/postgres/jsonb-value.js +9 -0
- package/dist/postgres/layer.d.ts +57 -0
- package/dist/postgres/layer.js +72 -0
- package/dist/postgres/migrations/advisory-lock.d.ts +47 -0
- package/dist/postgres/migrations/advisory-lock.js +59 -0
- package/dist/postgres/migrations/runner.d.ts +56 -0
- package/dist/postgres/migrations/runner.js +69 -0
- package/dist/postgres/migrations/sql-file-loader.d.ts +59 -0
- package/dist/postgres/migrations/sql-file-loader.js +99 -0
- package/dist/postgres/processed-event-registry.d.ts +13 -0
- package/dist/postgres/processed-event-registry.js +44 -0
- package/dist/postgres/readiness-probe.d.ts +27 -0
- package/dist/postgres/readiness-probe.js +33 -0
- package/dist/postgres/settings.d.ts +134 -0
- package/dist/postgres/settings.js +210 -0
- package/dist/postgres/sql/0001_processed_event.sql +4 -0
- package/dist/postgres/sql/0002_integration_event_outbox.sql +13 -0
- package/dist/postgres/sql/0003_document_number_counter.sql +6 -0
- package/dist/postgres/sql-failure.d.ts +19 -0
- package/dist/postgres/sql-failure.js +22 -0
- package/dist/postgres/store-failure.d.ts +17 -0
- package/dist/postgres/store-failure.js +16 -0
- package/dist/postgres/transaction-boundary.d.ts +13 -0
- package/dist/postgres/transaction-boundary.js +21 -0
- package/dist/publishing/event-publisher.sns.d.ts +66 -0
- package/dist/publishing/event-publisher.sns.js +75 -0
- package/dist/publishing/event-publisher.stdout.d.ts +15 -0
- package/dist/publishing/event-publisher.stdout.js +24 -0
- package/dist/publishing/index.d.ts +10 -0
- package/dist/publishing/index.js +10 -0
- package/dist/publishing/layer.d.ts +25 -0
- package/dist/publishing/layer.js +39 -0
- package/dist/publishing/settings.d.ts +60 -0
- package/dist/publishing/settings.js +85 -0
- package/dist/queue/consumer.d.ts +91 -0
- package/dist/queue/consumer.js +92 -0
- package/dist/queue/envelope.d.ts +39 -0
- package/dist/queue/envelope.js +39 -0
- package/dist/queue/failures.d.ts +33 -0
- package/dist/queue/failures.js +15 -0
- package/dist/queue/index.d.ts +14 -0
- package/dist/queue/index.js +14 -0
- package/dist/queue/layer.d.ts +33 -0
- package/dist/queue/layer.js +58 -0
- package/dist/queue/message-codec.d.ts +43 -0
- package/dist/queue/message-codec.js +33 -0
- package/dist/queue/queue-message-producer.d.ts +61 -0
- package/dist/queue/queue-message-producer.js +52 -0
- package/dist/queue/queue-message-source.d.ts +93 -0
- package/dist/queue/queue-message-source.js +86 -0
- package/dist/queue/readiness.d.ts +78 -0
- package/dist/queue/readiness.js +86 -0
- package/dist/queue/settings.d.ts +106 -0
- package/dist/queue/settings.js +160 -0
- package/dist/storage/blob-store.d.ts +84 -0
- package/dist/storage/blob-store.js +109 -0
- package/dist/storage/index.d.ts +9 -0
- package/dist/storage/index.js +9 -0
- package/dist/storage/layer.d.ts +17 -0
- package/dist/storage/layer.js +30 -0
- package/dist/storage/settings.d.ts +48 -0
- package/dist/storage/settings.js +65 -0
- package/dist/testing/blob-store.d.ts +27 -0
- package/dist/testing/blob-store.js +45 -0
- package/dist/testing/clock.d.ts +29 -0
- package/dist/testing/clock.js +41 -0
- package/dist/testing/dna-perturbator/attention.d.ts +55 -0
- package/dist/testing/dna-perturbator/attention.js +49 -0
- package/dist/testing/dna-perturbator/chains.d.ts +31 -0
- package/dist/testing/dna-perturbator/chains.js +53 -0
- package/dist/testing/dna-perturbator/report.d.ts +12 -0
- package/dist/testing/dna-perturbator/report.js +22 -0
- package/dist/testing/dna-perturbator/verdict.d.ts +26 -0
- package/dist/testing/dna-perturbator/verdict.js +28 -0
- package/dist/testing/dna-perturbator.d.ts +48 -0
- package/dist/testing/dna-perturbator.js +145 -0
- package/dist/testing/document-number-counter.d.ts +29 -0
- package/dist/testing/document-number-counter.js +39 -0
- package/dist/testing/event-publisher.d.ts +28 -0
- package/dist/testing/event-publisher.js +33 -0
- package/dist/testing/external-http-client.d.ts +29 -0
- package/dist/testing/external-http-client.js +49 -0
- package/dist/testing/index.d.ts +24 -0
- package/dist/testing/index.js +24 -0
- package/dist/testing/integration-event-outbox.d.ts +37 -0
- package/dist/testing/integration-event-outbox.js +67 -0
- package/dist/testing/mutation-settings.d.ts +71 -0
- package/dist/testing/mutation-settings.js +57 -0
- package/dist/testing/postgres-rows.d.ts +12 -0
- package/dist/testing/postgres-rows.js +19 -0
- package/dist/testing/processed-event-registry.d.ts +25 -0
- package/dist/testing/processed-event-registry.js +39 -0
- package/dist/testing/queue-message-producer.d.ts +27 -0
- package/dist/testing/queue-message-producer.js +41 -0
- package/dist/testing/queue-message-source.d.ts +54 -0
- package/dist/testing/queue-message-source.js +71 -0
- package/dist/testing/quiet-reporter.d.ts +86 -0
- package/dist/testing/quiet-reporter.js +81 -0
- package/dist/testing/readiness-probe.d.ts +27 -0
- package/dist/testing/readiness-probe.js +33 -0
- package/dist/testing/scenario.d.ts +94 -0
- package/dist/testing/scenario.js +81 -0
- package/dist/testing/transaction-boundary.d.ts +30 -0
- package/dist/testing/transaction-boundary.js +36 -0
- package/dist/testing/typed-failure.d.ts +8 -0
- package/dist/testing/typed-failure.js +15 -0
- package/dist/time/calendar-day.d.ts +83 -0
- package/dist/time/calendar-day.js +112 -0
- package/dist/time/failures.d.ts +51 -0
- package/dist/time/failures.js +56 -0
- package/dist/time/index.d.ts +11 -0
- package/dist/time/index.js +11 -0
- package/dist/time/instant.d.ts +64 -0
- package/dist/time/instant.js +67 -0
- package/dist/time/period.d.ts +45 -0
- package/dist/time/period.js +55 -0
- package/dist/vitest/container-reuse.d.ts +14 -0
- package/dist/vitest/container-reuse.js +15 -0
- package/dist/vitest/dotenv.d.ts +16 -0
- package/dist/vitest/dotenv.js +48 -0
- package/dist/vitest/effect-single-instance.d.ts +36 -0
- package/dist/vitest/effect-single-instance.js +125 -0
- package/dist/vitest/fast-check-single-instance.d.ts +11 -0
- package/dist/vitest/fast-check-single-instance.js +26 -0
- package/dist/vitest/index.d.ts +31 -0
- package/dist/vitest/index.js +30 -0
- package/dist/vitest/localstack-container.d.ts +32 -0
- package/dist/vitest/localstack-container.js +55 -0
- package/dist/vitest/port-contract-tester.d.ts +8 -0
- package/dist/vitest/port-contract-tester.js +1 -0
- package/dist/vitest/port-contracts/blob-store.contract.d.ts +22 -0
- package/dist/vitest/port-contracts/blob-store.contract.js +108 -0
- package/dist/vitest/port-contracts/document-number-counter.contract.d.ts +18 -0
- package/dist/vitest/port-contracts/document-number-counter.contract.js +90 -0
- package/dist/vitest/port-contracts/event-publisher.contract.d.ts +35 -0
- package/dist/vitest/port-contracts/event-publisher.contract.js +67 -0
- package/dist/vitest/port-contracts/external-http-client.contract.d.ts +35 -0
- package/dist/vitest/port-contracts/external-http-client.contract.js +52 -0
- package/dist/vitest/port-contracts/integration-event-outbox.contract.d.ts +23 -0
- package/dist/vitest/port-contracts/integration-event-outbox.contract.js +75 -0
- package/dist/vitest/port-contracts/processed-event-registry.contract.d.ts +18 -0
- package/dist/vitest/port-contracts/processed-event-registry.contract.js +84 -0
- package/dist/vitest/port-contracts/queue-message-producer.contract.d.ts +26 -0
- package/dist/vitest/port-contracts/queue-message-producer.contract.js +48 -0
- package/dist/vitest/port-contracts/queue-message-source.contract.d.ts +29 -0
- package/dist/vitest/port-contracts/queue-message-source.contract.js +71 -0
- package/dist/vitest/port-contracts/readiness-probe.contract.d.ts +18 -0
- package/dist/vitest/port-contracts/readiness-probe.contract.js +41 -0
- package/dist/vitest/port-contracts/transaction-boundary.contract.d.ts +21 -0
- package/dist/vitest/port-contracts/transaction-boundary.contract.js +64 -0
- package/dist/vitest/postgres-container.d.ts +57 -0
- package/dist/vitest/postgres-container.js +118 -0
- package/dist/vitest/reader-port-contract.d.ts +41 -0
- package/dist/vitest/reader-port-contract.js +43 -0
- package/dist/vitest/repo-source-files.d.ts +37 -0
- package/dist/vitest/repo-source-files.js +61 -0
- package/dist/vitest/reset-tables.d.ts +18 -0
- package/dist/vitest/reset-tables.js +30 -0
- package/dist/vitest/settings-contract.d.ts +38 -0
- package/dist/vitest/settings-contract.js +77 -0
- package/dist/vitest/stub-server.d.ts +43 -0
- package/dist/vitest/stub-server.js +60 -0
- package/dist/vitest/term-registry.json +730 -0
- package/dist/vitest/testclock-fork-isolated-files.d.ts +13 -0
- package/dist/vitest/testclock-fork-isolated-files.js +18 -0
- package/dist/vitest/testclock-fork-isolation.d.ts +77 -0
- package/dist/vitest/testclock-fork-isolation.js +188 -0
- package/dist/workflow/claim-once.d.ts +16 -0
- package/dist/workflow/claim-once.js +9 -0
- package/dist/workflow/failures.d.ts +47 -0
- package/dist/workflow/failures.js +22 -0
- package/dist/workflow/index.d.ts +12 -0
- package/dist/workflow/index.js +12 -0
- package/dist/workflow/on-claim.d.ts +17 -0
- package/dist/workflow/on-claim.js +8 -0
- package/dist/workflow/publish-pending-events.d.ts +31 -0
- package/dist/workflow/publish-pending-events.js +31 -0
- package/dist/workflow/transactionally.d.ts +8 -0
- package/dist/workflow/transactionally.js +8 -0
- package/dist/workflow/versioned-payload.d.ts +35 -0
- package/dist/workflow/versioned-payload.js +43 -0
- 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>;
|