@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,67 @@
|
|
|
1
|
+
import { describe, expect } from '@effect/vitest';
|
|
2
|
+
import { EventPublisher } from '@satorio/machinery/ports';
|
|
3
|
+
import { DateTime, Effect } from 'effect';
|
|
4
|
+
const HOW_MANY_TIMES_TO_ASK = 20;
|
|
5
|
+
/**
|
|
6
|
+
* One message, built the way an outbox hands it over.
|
|
7
|
+
*/
|
|
8
|
+
export const outboxEvent = (messageId, payload) => ({
|
|
9
|
+
messageId,
|
|
10
|
+
eventType: 'SomethingHappened',
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
occurredAt: DateTime.unsafeMake('2026-03-01T12:00:00.000Z'),
|
|
13
|
+
payload,
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* One contract, every event publisher. The same body runs against an
|
|
17
|
+
* in-memory publisher and a live SNS topic, so a difference between them
|
|
18
|
+
* shows up as a red test rather than as a surprise in production. Hand
|
|
19
|
+
* it the port's unavailable stand-in as a fourth argument and it walks
|
|
20
|
+
* the refusal too.
|
|
21
|
+
*
|
|
22
|
+
* What this contract cannot prove: that a subscriber reads the envelope
|
|
23
|
+
* the publisher wrote. The shape on the wire is each implementation's
|
|
24
|
+
* own — `stdoutLineOf` carries a channel, `snsMessageBodyOf` does not —
|
|
25
|
+
* so the live test in `src/publishing/ports.localstack.test.ts` owns
|
|
26
|
+
* that branch, reading the envelope back off a queue subscribed to the
|
|
27
|
+
* topic.
|
|
28
|
+
*/
|
|
29
|
+
export const eventPublisherContract = (it, implementationName, publisher, unavailable) => {
|
|
30
|
+
const run = (effect) => Effect.provide(effect, publisher.layer);
|
|
31
|
+
const deliveredUntil = (howMany) => publisher.delivered.pipe(Effect.repeat({
|
|
32
|
+
until: (ids) => ids.length >= howMany,
|
|
33
|
+
times: HOW_MANY_TIMES_TO_ASK,
|
|
34
|
+
}));
|
|
35
|
+
describe(`EventPublisher contract (${implementationName})`, () => {
|
|
36
|
+
it.effect('a published message reaches whatever stands behind it', () => run(Effect.gen(function* () {
|
|
37
|
+
const publishing = yield* EventPublisher;
|
|
38
|
+
yield* publishing.publish([outboxEvent('contract-1', { seq: 1 })]);
|
|
39
|
+
const delivered = yield* deliveredUntil(1);
|
|
40
|
+
expect(delivered).toContain('contract-1');
|
|
41
|
+
})));
|
|
42
|
+
it.effect('every message of a batch goes, not only the first', () => run(Effect.gen(function* () {
|
|
43
|
+
const publishing = yield* EventPublisher;
|
|
44
|
+
yield* publishing.publish([
|
|
45
|
+
outboxEvent('contract-2', { seq: 2 }),
|
|
46
|
+
outboxEvent('contract-3', { seq: 3 }),
|
|
47
|
+
]);
|
|
48
|
+
const delivered = yield* deliveredUntil(3);
|
|
49
|
+
expect(delivered).toContain('contract-2');
|
|
50
|
+
expect(delivered).toContain('contract-3');
|
|
51
|
+
})));
|
|
52
|
+
it.effect('an empty batch publishes nothing and fails nothing', () => run(Effect.gen(function* () {
|
|
53
|
+
const publishing = yield* EventPublisher;
|
|
54
|
+
yield* publishing.publish([]);
|
|
55
|
+
})));
|
|
56
|
+
});
|
|
57
|
+
if (unavailable === undefined)
|
|
58
|
+
return;
|
|
59
|
+
describe(`EventPublisher contract (${implementationName}, not mounted)`, () => {
|
|
60
|
+
it.effect('publish fails, and the caller reads the refusal', () => Effect.gen(function* () {
|
|
61
|
+
const publishing = yield* EventPublisher;
|
|
62
|
+
const refusal = yield* Effect.flip(publishing.publish([outboxEvent('contract-4', {})]));
|
|
63
|
+
expect(refusal._tag).toBe('EventPublisherUnavailableError');
|
|
64
|
+
expect(refusal.operation).toBe('publish');
|
|
65
|
+
}).pipe(Effect.provide(unavailable)));
|
|
66
|
+
});
|
|
67
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ExternalHttpClient } from '@satorio/machinery/ports';
|
|
2
|
+
import { type Layer } from 'effect';
|
|
3
|
+
import type { PortContractTester } from '../port-contract-tester.js';
|
|
4
|
+
/**
|
|
5
|
+
* One external service, described the way a contract can hold it: the
|
|
6
|
+
* layer that mounts the port, and the three paths whatever stands behind
|
|
7
|
+
* it answers — one that returns a body with the title given here, one it
|
|
8
|
+
* does not serve at all, and one whose body does not fit the schema.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export interface ExternalServiceUnderTest {
|
|
13
|
+
readonly layer: Layer.Layer<ExternalHttpClient>;
|
|
14
|
+
readonly answered: string;
|
|
15
|
+
readonly answeredTitle: string;
|
|
16
|
+
readonly notServed: string;
|
|
17
|
+
readonly malformed: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One contract, every external HTTP client. The same body runs against
|
|
21
|
+
* the in-memory stand-in and against a real socket, so the three
|
|
22
|
+
* failures stay three: the service did not answer, the service said a
|
|
23
|
+
* specific no, and the body did not fit the schema. Hand it the port's
|
|
24
|
+
* unavailable stand-in as a fourth argument and it walks the refusal
|
|
25
|
+
* too.
|
|
26
|
+
*
|
|
27
|
+
* What this contract cannot prove: that a transient failure is retried
|
|
28
|
+
* and a refusal is not. Retrying takes a transport that answers
|
|
29
|
+
* differently on the second call, and no stand-in reaches that from the
|
|
30
|
+
* port alone — `src/integration/external-http-client.test.ts` owns that
|
|
31
|
+
* branch, driving a scripted transport past the retry schedule on a
|
|
32
|
+
* `TestClock`. Nor does it prove the timeout: that branch belongs to the
|
|
33
|
+
* same test, because a stand-in that answers at once can never be late.
|
|
34
|
+
*/
|
|
35
|
+
export declare const externalHttpClientContract: (it: PortContractTester, implementationName: string, service: ExternalServiceUnderTest, unavailable?: Layer.Layer<ExternalHttpClient>) => void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect } from '@effect/vitest';
|
|
2
|
+
import { ExternalHttpClient } from '@satorio/machinery/ports';
|
|
3
|
+
import { Effect, Schema } from 'effect';
|
|
4
|
+
const AnsweredBody = Schema.Struct({ title: Schema.String });
|
|
5
|
+
/**
|
|
6
|
+
* One contract, every external HTTP client. The same body runs against
|
|
7
|
+
* the in-memory stand-in and against a real socket, so the three
|
|
8
|
+
* failures stay three: the service did not answer, the service said a
|
|
9
|
+
* specific no, and the body did not fit the schema. Hand it the port's
|
|
10
|
+
* unavailable stand-in as a fourth argument and it walks the refusal
|
|
11
|
+
* too.
|
|
12
|
+
*
|
|
13
|
+
* What this contract cannot prove: that a transient failure is retried
|
|
14
|
+
* and a refusal is not. Retrying takes a transport that answers
|
|
15
|
+
* differently on the second call, and no stand-in reaches that from the
|
|
16
|
+
* port alone — `src/integration/external-http-client.test.ts` owns that
|
|
17
|
+
* branch, driving a scripted transport past the retry schedule on a
|
|
18
|
+
* `TestClock`. Nor does it prove the timeout: that branch belongs to the
|
|
19
|
+
* same test, because a stand-in that answers at once can never be late.
|
|
20
|
+
*/
|
|
21
|
+
export const externalHttpClientContract = (it, implementationName, service, unavailable) => {
|
|
22
|
+
const run = (effect) => Effect.provide(effect, service.layer);
|
|
23
|
+
describe(`ExternalHttpClient contract (${implementationName})`, () => {
|
|
24
|
+
it.effect('a served path answers with the decoded body', () => run(Effect.gen(function* () {
|
|
25
|
+
const client = yield* ExternalHttpClient;
|
|
26
|
+
const answered = yield* client.getJson(service.answered, AnsweredBody);
|
|
27
|
+
expect(answered.title).toBe(service.answeredTitle);
|
|
28
|
+
})));
|
|
29
|
+
it.effect('a path the service does not serve is a refusal, not silence', () => run(Effect.gen(function* () {
|
|
30
|
+
const client = yield* ExternalHttpClient;
|
|
31
|
+
const refusal = yield* Effect.flip(client.getJson(service.notServed, AnsweredBody));
|
|
32
|
+
expect(refusal._tag).toBe('ExternalServiceRefusedError');
|
|
33
|
+
})));
|
|
34
|
+
it.effect('a body that does not fit the schema is its own failure', () => run(Effect.gen(function* () {
|
|
35
|
+
const client = yield* ExternalHttpClient;
|
|
36
|
+
const failure = yield* Effect.flip(client.getJson(service.malformed, AnsweredBody));
|
|
37
|
+
expect(failure._tag).toBe('ExternalServiceResponseMalformedError');
|
|
38
|
+
})));
|
|
39
|
+
});
|
|
40
|
+
if (unavailable === undefined)
|
|
41
|
+
return;
|
|
42
|
+
describe(`ExternalHttpClient contract (${implementationName}, not mounted)`, () => {
|
|
43
|
+
it.effect('every call fails, and the caller reads the refusal', () => Effect.gen(function* () {
|
|
44
|
+
const client = yield* ExternalHttpClient;
|
|
45
|
+
const refusal = yield* Effect.flip(client.getJson(service.answered, AnsweredBody));
|
|
46
|
+
expect(refusal._tag).toBe('ExternalServiceUnavailableError');
|
|
47
|
+
if (refusal._tag === 'ExternalServiceUnavailableError') {
|
|
48
|
+
expect(refusal.operation).toBe('getJson');
|
|
49
|
+
}
|
|
50
|
+
}).pipe(Effect.provide(unavailable)));
|
|
51
|
+
});
|
|
52
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { IntegrationEventOutbox, type OutboxMessage } from '@satorio/machinery/ports';
|
|
2
|
+
import { type Layer } from 'effect';
|
|
3
|
+
import type { PortContractTester } from '../port-contract-tester.js';
|
|
4
|
+
/**
|
|
5
|
+
* One outbox message a contract enqueues and reads back, so every
|
|
6
|
+
* implementation is asked the same question.
|
|
7
|
+
*/
|
|
8
|
+
export declare const outboxMessage: (messageId: string) => OutboxMessage;
|
|
9
|
+
/**
|
|
10
|
+
* One contract, every integration event outbox. The same body runs against an
|
|
11
|
+
* in-memory outbox and a live Postgres outbox, so a difference between
|
|
12
|
+
* them shows up as a red test, not a surprise in production. Hand it the
|
|
13
|
+
* port's unavailable stand-in as a fourth argument and it walks the
|
|
14
|
+
* refusal too: the write fails, and the failure reaches the caller.
|
|
15
|
+
*
|
|
16
|
+
* What this contract cannot prove: that a message enqueued inside a
|
|
17
|
+
* transaction disappears with it when the transaction rolls back. The
|
|
18
|
+
* in-memory outbox writes to a `Ref` that only an in-memory transaction
|
|
19
|
+
* boundary can restore; only the live run in
|
|
20
|
+
* `src/postgres/ports.postgres.test.ts` puts one real transaction around
|
|
21
|
+
* the state change and the message together.
|
|
22
|
+
*/
|
|
23
|
+
export declare const integrationEventOutboxContract: (it: PortContractTester, implementationName: string, layer: Layer.Layer<IntegrationEventOutbox>, unavailable?: Layer.Layer<IntegrationEventOutbox>) => void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect } from '@effect/vitest';
|
|
2
|
+
import { IntegrationEventOutbox, } from '@satorio/machinery/ports';
|
|
3
|
+
import { DateTime, Effect } from 'effect';
|
|
4
|
+
const OCCURRED_AT = DateTime.unsafeMake('2026-03-01T12:00:00.000Z');
|
|
5
|
+
/**
|
|
6
|
+
* One outbox message a contract enqueues and reads back, so every
|
|
7
|
+
* implementation is asked the same question.
|
|
8
|
+
*/
|
|
9
|
+
export const outboxMessage = (messageId) => ({
|
|
10
|
+
messageId,
|
|
11
|
+
eventType: 'SomethingHappened',
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
occurredAt: OCCURRED_AT,
|
|
14
|
+
payload: { subject: messageId },
|
|
15
|
+
});
|
|
16
|
+
const idsOf = (messages) => messages.map((message) => message.messageId);
|
|
17
|
+
/**
|
|
18
|
+
* One contract, every integration event outbox. The same body runs against an
|
|
19
|
+
* in-memory outbox and a live Postgres outbox, so a difference between
|
|
20
|
+
* them shows up as a red test, not a surprise in production. Hand it the
|
|
21
|
+
* port's unavailable stand-in as a fourth argument and it walks the
|
|
22
|
+
* refusal too: the write fails, and the failure reaches the caller.
|
|
23
|
+
*
|
|
24
|
+
* What this contract cannot prove: that a message enqueued inside a
|
|
25
|
+
* transaction disappears with it when the transaction rolls back. The
|
|
26
|
+
* in-memory outbox writes to a `Ref` that only an in-memory transaction
|
|
27
|
+
* boundary can restore; only the live run in
|
|
28
|
+
* `src/postgres/ports.postgres.test.ts` puts one real transaction around
|
|
29
|
+
* the state change and the message together.
|
|
30
|
+
*/
|
|
31
|
+
export const integrationEventOutboxContract = (it, implementationName, layer, unavailable) => {
|
|
32
|
+
const run = (effect) => Effect.provide(effect, layer);
|
|
33
|
+
describe(`IntegrationEventOutbox contract (${implementationName})`, () => {
|
|
34
|
+
it.effect('a message that was put waits among the undispatched', () => run(Effect.gen(function* () {
|
|
35
|
+
const outbox = yield* IntegrationEventOutbox;
|
|
36
|
+
yield* outbox.enqueue([outboxMessage('outbox-1')]);
|
|
37
|
+
const pending = yield* outbox.pending(10);
|
|
38
|
+
expect(idsOf(pending)).toContain('outbox-1');
|
|
39
|
+
})));
|
|
40
|
+
it.effect('a message marked dispatched no longer shows as undispatched', () => run(Effect.gen(function* () {
|
|
41
|
+
const outbox = yield* IntegrationEventOutbox;
|
|
42
|
+
yield* outbox.enqueue([outboxMessage('outbox-2')]);
|
|
43
|
+
yield* outbox.markDispatched(['outbox-2']);
|
|
44
|
+
const pending = yield* outbox.pending(10);
|
|
45
|
+
expect(idsOf(pending)).not.toContain('outbox-2');
|
|
46
|
+
})));
|
|
47
|
+
it.effect('enqueuing the same messageId again does not duplicate it', () => run(Effect.gen(function* () {
|
|
48
|
+
const outbox = yield* IntegrationEventOutbox;
|
|
49
|
+
yield* outbox.enqueue([outboxMessage('outbox-3')]);
|
|
50
|
+
yield* outbox.enqueue([outboxMessage('outbox-3')]);
|
|
51
|
+
const pending = yield* outbox.pending(100);
|
|
52
|
+
expect(idsOf(pending).filter((id) => id === 'outbox-3')).toHaveLength(1);
|
|
53
|
+
})));
|
|
54
|
+
it.effect('the limit caps the batch size', () => run(Effect.gen(function* () {
|
|
55
|
+
const outbox = yield* IntegrationEventOutbox;
|
|
56
|
+
yield* outbox.enqueue([
|
|
57
|
+
outboxMessage('outbox-4'),
|
|
58
|
+
outboxMessage('outbox-5'),
|
|
59
|
+
outboxMessage('outbox-6'),
|
|
60
|
+
]);
|
|
61
|
+
const pending = yield* outbox.pending(2);
|
|
62
|
+
expect(pending).toHaveLength(2);
|
|
63
|
+
})));
|
|
64
|
+
});
|
|
65
|
+
if (unavailable === undefined)
|
|
66
|
+
return;
|
|
67
|
+
describe(`IntegrationEventOutbox contract (${implementationName}, not mounted)`, () => {
|
|
68
|
+
it.effect('the write fails, and the caller reads the refusal', () => Effect.gen(function* () {
|
|
69
|
+
const outbox = yield* IntegrationEventOutbox;
|
|
70
|
+
const refusal = yield* Effect.flip(outbox.enqueue([outboxMessage('outbox-7')]));
|
|
71
|
+
expect(refusal._tag).toBe('IntegrationEventOutboxUnavailableError');
|
|
72
|
+
expect(refusal.operation).toBe('enqueue');
|
|
73
|
+
}).pipe(Effect.provide(unavailable)));
|
|
74
|
+
});
|
|
75
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ProcessedEventRegistry } from '@satorio/machinery/ports';
|
|
2
|
+
import { type Layer } from 'effect';
|
|
3
|
+
import type { PortContractTester } from '../port-contract-tester.js';
|
|
4
|
+
/**
|
|
5
|
+
* One contract, every processed event registry. The same body runs against an
|
|
6
|
+
* in-memory registry and a live Postgres registry, so a difference between
|
|
7
|
+
* them shows up as a red test, not a surprise in production. Hand it the
|
|
8
|
+
* port's unavailable stand-in as a fourth argument and it walks the
|
|
9
|
+
* refusal too: the claim fails, and the failure reaches the caller.
|
|
10
|
+
*
|
|
11
|
+
* What this contract cannot prove: that two callers claiming the same id
|
|
12
|
+
* at the same instant get one acceptance and one AlreadyProcessed. The
|
|
13
|
+
* in-memory registry runs one fiber at a time over a `Ref`, so it cannot
|
|
14
|
+
* lose that race; only the live run in
|
|
15
|
+
* `src/postgres/ports.postgres.test.ts` puts a real unique constraint
|
|
16
|
+
* behind the claim.
|
|
17
|
+
*/
|
|
18
|
+
export declare const processedEventRegistryContract: (it: PortContractTester, implementationName: string, layer: Layer.Layer<ProcessedEventRegistry>, unavailable?: Layer.Layer<ProcessedEventRegistry>) => void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect } from '@effect/vitest';
|
|
2
|
+
import { ProcessedEventRegistry } from '@satorio/machinery/ports';
|
|
3
|
+
import { DateTime, Effect } from 'effect';
|
|
4
|
+
const FIRST_CLAIM_AT = DateTime.unsafeMake('2026-02-10T09:30:00.000Z');
|
|
5
|
+
const SECOND_CLAIM_AT = DateTime.unsafeMake('2026-02-11T18:00:00.000Z');
|
|
6
|
+
/**
|
|
7
|
+
* One contract, every processed event registry. The same body runs against an
|
|
8
|
+
* in-memory registry and a live Postgres registry, so a difference between
|
|
9
|
+
* them shows up as a red test, not a surprise in production. Hand it the
|
|
10
|
+
* port's unavailable stand-in as a fourth argument and it walks the
|
|
11
|
+
* refusal too: the claim fails, and the failure reaches the caller.
|
|
12
|
+
*
|
|
13
|
+
* What this contract cannot prove: that two callers claiming the same id
|
|
14
|
+
* at the same instant get one acceptance and one AlreadyProcessed. The
|
|
15
|
+
* in-memory registry runs one fiber at a time over a `Ref`, so it cannot
|
|
16
|
+
* lose that race; only the live run in
|
|
17
|
+
* `src/postgres/ports.postgres.test.ts` puts a real unique constraint
|
|
18
|
+
* behind the claim.
|
|
19
|
+
*/
|
|
20
|
+
export const processedEventRegistryContract = (it, implementationName, layer, unavailable) => {
|
|
21
|
+
const run = (effect) => Effect.provide(effect, layer);
|
|
22
|
+
describe(`ProcessedEventRegistry contract (${implementationName})`, () => {
|
|
23
|
+
it.effect('the first claim on an eventId gives ClaimAccepted', () => run(Effect.gen(function* () {
|
|
24
|
+
const registry = yield* ProcessedEventRegistry;
|
|
25
|
+
const claim = yield* registry.claim({
|
|
26
|
+
eventId: 'event-1',
|
|
27
|
+
processedAt: FIRST_CLAIM_AT,
|
|
28
|
+
});
|
|
29
|
+
expect(claim._tag).toBe('ClaimAccepted');
|
|
30
|
+
})));
|
|
31
|
+
it.effect('a second claim on the same eventId gives AlreadyProcessed', () => run(Effect.gen(function* () {
|
|
32
|
+
const registry = yield* ProcessedEventRegistry;
|
|
33
|
+
yield* registry.claim({
|
|
34
|
+
eventId: 'event-2',
|
|
35
|
+
processedAt: FIRST_CLAIM_AT,
|
|
36
|
+
});
|
|
37
|
+
const claim = yield* registry.claim({
|
|
38
|
+
eventId: 'event-2',
|
|
39
|
+
processedAt: SECOND_CLAIM_AT,
|
|
40
|
+
});
|
|
41
|
+
expect(claim._tag).toBe('AlreadyProcessed');
|
|
42
|
+
})));
|
|
43
|
+
it.effect('AlreadyProcessed carries the first claim time, not the repeat', () => run(Effect.gen(function* () {
|
|
44
|
+
const registry = yield* ProcessedEventRegistry;
|
|
45
|
+
yield* registry.claim({
|
|
46
|
+
eventId: 'event-3',
|
|
47
|
+
processedAt: FIRST_CLAIM_AT,
|
|
48
|
+
});
|
|
49
|
+
const claim = yield* registry.claim({
|
|
50
|
+
eventId: 'event-3',
|
|
51
|
+
processedAt: SECOND_CLAIM_AT,
|
|
52
|
+
});
|
|
53
|
+
expect(claim._tag).toBe('AlreadyProcessed');
|
|
54
|
+
if (claim._tag === 'AlreadyProcessed') {
|
|
55
|
+
expect(DateTime.toEpochMillis(claim.processedAt)).toBe(DateTime.toEpochMillis(FIRST_CLAIM_AT));
|
|
56
|
+
}
|
|
57
|
+
})));
|
|
58
|
+
it.effect('a claim on a different eventId gives ClaimAccepted', () => run(Effect.gen(function* () {
|
|
59
|
+
const registry = yield* ProcessedEventRegistry;
|
|
60
|
+
yield* registry.claim({
|
|
61
|
+
eventId: 'event-4',
|
|
62
|
+
processedAt: FIRST_CLAIM_AT,
|
|
63
|
+
});
|
|
64
|
+
const claim = yield* registry.claim({
|
|
65
|
+
eventId: 'event-5',
|
|
66
|
+
processedAt: SECOND_CLAIM_AT,
|
|
67
|
+
});
|
|
68
|
+
expect(claim._tag).toBe('ClaimAccepted');
|
|
69
|
+
})));
|
|
70
|
+
});
|
|
71
|
+
if (unavailable === undefined)
|
|
72
|
+
return;
|
|
73
|
+
describe(`ProcessedEventRegistry contract (${implementationName}, not mounted)`, () => {
|
|
74
|
+
it.effect('the claim fails, and the caller reads the refusal', () => Effect.gen(function* () {
|
|
75
|
+
const registry = yield* ProcessedEventRegistry;
|
|
76
|
+
const refusal = yield* Effect.flip(registry.claim({
|
|
77
|
+
eventId: 'event-6',
|
|
78
|
+
processedAt: FIRST_CLAIM_AT,
|
|
79
|
+
}));
|
|
80
|
+
expect(refusal._tag).toBe('ProcessedEventRegistryUnavailableError');
|
|
81
|
+
expect(refusal.operation).toBe('claim');
|
|
82
|
+
}).pipe(Effect.provide(unavailable)));
|
|
83
|
+
});
|
|
84
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { QueueMessageProducer } from '@satorio/machinery/ports';
|
|
2
|
+
import { Effect, type Layer } from 'effect';
|
|
3
|
+
import type { PortContractTester } from '../port-contract-tester.js';
|
|
4
|
+
/**
|
|
5
|
+
* One producer, described the way a contract can hold it: the layer that
|
|
6
|
+
* mounts it, and the one call that reads back the bodies that landed on
|
|
7
|
+
* the queue behind it.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export interface ProducerUnderTest {
|
|
12
|
+
readonly layer: Layer.Layer<QueueMessageProducer>;
|
|
13
|
+
readonly taken: Effect.Effect<readonly string[]>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One contract, every message producer. It holds the only promise a
|
|
17
|
+
* producer makes: a body handed to `send` lands on the queue, whole and
|
|
18
|
+
* unchanged. Hand it the port's unavailable stand-in as a fourth
|
|
19
|
+
* argument and it walks the refusal too.
|
|
20
|
+
*
|
|
21
|
+
* What this contract cannot prove: the order two sends arrive in. An SQS
|
|
22
|
+
* standard queue makes no such promise, so the contract asserts what did
|
|
23
|
+
* land rather than the order it landed in, and no implementation is held
|
|
24
|
+
* to a promise the technology does not keep.
|
|
25
|
+
*/
|
|
26
|
+
export declare const queueMessageProducerContract: (it: PortContractTester, implementationName: string, producer: ProducerUnderTest, unavailable?: Layer.Layer<QueueMessageProducer>) => void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect } from '@effect/vitest';
|
|
2
|
+
import { QueueMessageProducer } from '@satorio/machinery/ports';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
const HOW_MANY_TIMES_TO_ASK = 20;
|
|
5
|
+
/**
|
|
6
|
+
* One contract, every message producer. It holds the only promise a
|
|
7
|
+
* producer makes: a body handed to `send` lands on the queue, whole and
|
|
8
|
+
* unchanged. Hand it the port's unavailable stand-in as a fourth
|
|
9
|
+
* argument and it walks the refusal too.
|
|
10
|
+
*
|
|
11
|
+
* What this contract cannot prove: the order two sends arrive in. An SQS
|
|
12
|
+
* standard queue makes no such promise, so the contract asserts what did
|
|
13
|
+
* land rather than the order it landed in, and no implementation is held
|
|
14
|
+
* to a promise the technology does not keep.
|
|
15
|
+
*/
|
|
16
|
+
export const queueMessageProducerContract = (it, implementationName, producer, unavailable) => {
|
|
17
|
+
const run = (effect) => Effect.provide(effect, producer.layer);
|
|
18
|
+
const takenUntil = (howMany) => producer.taken.pipe(Effect.repeat({
|
|
19
|
+
until: (bodies) => bodies.length >= howMany,
|
|
20
|
+
times: HOW_MANY_TIMES_TO_ASK,
|
|
21
|
+
}));
|
|
22
|
+
describe(`QueueMessageProducer contract (${implementationName})`, () => {
|
|
23
|
+
it.effect('a sent body lands on the queue unchanged', () => run(Effect.gen(function* () {
|
|
24
|
+
const sending = yield* QueueMessageProducer;
|
|
25
|
+
yield* sending.send('a body the queue keeps whole');
|
|
26
|
+
const bodies = yield* takenUntil(1);
|
|
27
|
+
expect(bodies).toContain('a body the queue keeps whole');
|
|
28
|
+
})));
|
|
29
|
+
it.effect('two sends put two messages on the queue', () => run(Effect.gen(function* () {
|
|
30
|
+
const sending = yield* QueueMessageProducer;
|
|
31
|
+
yield* sending.send('first of two');
|
|
32
|
+
yield* sending.send('second of two');
|
|
33
|
+
const bodies = yield* takenUntil(2);
|
|
34
|
+
expect(bodies).toContain('first of two');
|
|
35
|
+
expect(bodies).toContain('second of two');
|
|
36
|
+
})));
|
|
37
|
+
});
|
|
38
|
+
if (unavailable === undefined)
|
|
39
|
+
return;
|
|
40
|
+
describe(`QueueMessageProducer contract (${implementationName}, not mounted)`, () => {
|
|
41
|
+
it.effect('send fails, and the caller reads the refusal', () => Effect.gen(function* () {
|
|
42
|
+
const sending = yield* QueueMessageProducer;
|
|
43
|
+
const refusal = yield* Effect.flip(sending.send('never sent'));
|
|
44
|
+
expect(refusal._tag).toBe('QueueMessageProducerUnavailableError');
|
|
45
|
+
expect(refusal.operation).toBe('send');
|
|
46
|
+
}).pipe(Effect.provide(unavailable)));
|
|
47
|
+
});
|
|
48
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { QueueMessageSource } from '@satorio/machinery/ports';
|
|
2
|
+
import { Effect, type Layer } from 'effect';
|
|
3
|
+
import type { PortContractTester } from '../port-contract-tester.js';
|
|
4
|
+
/**
|
|
5
|
+
* One queue, described the way a contract can hold it: the layer that
|
|
6
|
+
* mounts the source, and the one call that puts a message on the queue
|
|
7
|
+
* behind it. An in-memory queue writes to its own `Ref`; a live SQS
|
|
8
|
+
* queue sends with the producer.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export interface QueueUnderTest {
|
|
13
|
+
readonly layer: Layer.Layer<QueueMessageSource>;
|
|
14
|
+
readonly put: (body: string) => Effect.Effect<void>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One contract, every message source. The same body runs against an
|
|
18
|
+
* in-memory queue and a live SQS queue, so a difference between them
|
|
19
|
+
* shows up as a red test rather than as a surprise in production. Hand
|
|
20
|
+
* it the port's unavailable stand-in as a fourth argument and it walks
|
|
21
|
+
* the refusal too: receive and acknowledge both fail, and the failure
|
|
22
|
+
* reaches the caller.
|
|
23
|
+
*
|
|
24
|
+
* What this contract cannot prove: that a message nobody acknowledges
|
|
25
|
+
* comes back. Redelivery after the visibility timeout is SQS's promise
|
|
26
|
+
* and no in-memory stand-in keeps a clock, so the live test in
|
|
27
|
+
* `src/queue/ports.localstack.test.ts` owns that branch.
|
|
28
|
+
*/
|
|
29
|
+
export declare const queueMessageSourceContract: (it: PortContractTester, implementationName: string, queue: QueueUnderTest, unavailable?: Layer.Layer<QueueMessageSource>) => void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect } from '@effect/vitest';
|
|
2
|
+
import { QueueMessageSource } from '@satorio/machinery/ports';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
const HOW_MANY_TIMES_TO_ASK = 20;
|
|
5
|
+
/**
|
|
6
|
+
* One contract, every message source. The same body runs against an
|
|
7
|
+
* in-memory queue and a live SQS queue, so a difference between them
|
|
8
|
+
* shows up as a red test rather than as a surprise in production. Hand
|
|
9
|
+
* it the port's unavailable stand-in as a fourth argument and it walks
|
|
10
|
+
* the refusal too: receive and acknowledge both fail, and the failure
|
|
11
|
+
* reaches the caller.
|
|
12
|
+
*
|
|
13
|
+
* What this contract cannot prove: that a message nobody acknowledges
|
|
14
|
+
* comes back. Redelivery after the visibility timeout is SQS's promise
|
|
15
|
+
* and no in-memory stand-in keeps a clock, so the live test in
|
|
16
|
+
* `src/queue/ports.localstack.test.ts` owns that branch.
|
|
17
|
+
*/
|
|
18
|
+
export const queueMessageSourceContract = (it, implementationName, queue, unavailable) => {
|
|
19
|
+
const run = (effect) => Effect.provide(effect, queue.layer);
|
|
20
|
+
const receiveSomething = Effect.flatMap(QueueMessageSource, (source) => source.receive.pipe(Effect.repeat({
|
|
21
|
+
until: (batch) => batch.length > 0,
|
|
22
|
+
times: HOW_MANY_TIMES_TO_ASK,
|
|
23
|
+
})));
|
|
24
|
+
describe(`QueueMessageSource contract (${implementationName})`, () => {
|
|
25
|
+
it.effect('what was put on the queue comes back whole', () => run(Effect.gen(function* () {
|
|
26
|
+
yield* queue.put('body-of-the-first-message');
|
|
27
|
+
const received = yield* receiveSomething;
|
|
28
|
+
const first = received[0];
|
|
29
|
+
expect(first?.body).toBe('body-of-the-first-message');
|
|
30
|
+
expect(first?.messageId).not.toBe('');
|
|
31
|
+
expect(first?.receiptHandle).not.toBe('');
|
|
32
|
+
})));
|
|
33
|
+
it.effect('an acknowledged message does not come back', () => run(Effect.gen(function* () {
|
|
34
|
+
const source = yield* QueueMessageSource;
|
|
35
|
+
yield* queue.put('body-of-the-second-message');
|
|
36
|
+
const received = yield* receiveSomething;
|
|
37
|
+
for (const message of received) {
|
|
38
|
+
yield* source.acknowledge(message.receiptHandle);
|
|
39
|
+
}
|
|
40
|
+
const afterwards = yield* source.receive;
|
|
41
|
+
expect(afterwards.map((message) => message.body)).not.toContain('body-of-the-second-message');
|
|
42
|
+
})));
|
|
43
|
+
it.effect('acknowledging a receipt twice is not a failure', () => run(Effect.gen(function* () {
|
|
44
|
+
const source = yield* QueueMessageSource;
|
|
45
|
+
yield* queue.put('body-of-the-third-message');
|
|
46
|
+
const received = yield* receiveSomething;
|
|
47
|
+
const receiptHandle = received[0]?.receiptHandle ?? '';
|
|
48
|
+
yield* source.acknowledge(receiptHandle);
|
|
49
|
+
yield* source.acknowledge(receiptHandle);
|
|
50
|
+
})));
|
|
51
|
+
});
|
|
52
|
+
if (unavailable === undefined)
|
|
53
|
+
return;
|
|
54
|
+
describe(`QueueMessageSource contract (${implementationName}, not mounted)`, () => {
|
|
55
|
+
it.effect('every operation fails, and the caller reads the refusal', () => Effect.gen(function* () {
|
|
56
|
+
const source = yield* QueueMessageSource;
|
|
57
|
+
const refusals = yield* Effect.all([
|
|
58
|
+
Effect.flip(source.receive),
|
|
59
|
+
Effect.flip(source.acknowledge('receipt-1')),
|
|
60
|
+
]);
|
|
61
|
+
expect(refusals.map((refusal) => refusal._tag)).toEqual([
|
|
62
|
+
'QueueMessageSourceUnavailableError',
|
|
63
|
+
'QueueMessageSourceUnavailableError',
|
|
64
|
+
]);
|
|
65
|
+
expect(refusals.map((refusal) => refusal.operation)).toEqual([
|
|
66
|
+
'receive',
|
|
67
|
+
'acknowledge',
|
|
68
|
+
]);
|
|
69
|
+
}).pipe(Effect.provide(unavailable)));
|
|
70
|
+
});
|
|
71
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ReadinessProbe } from '@satorio/machinery/ports';
|
|
2
|
+
import { type Layer } from 'effect';
|
|
3
|
+
import type { PortContractTester } from '../port-contract-tester.js';
|
|
4
|
+
/**
|
|
5
|
+
* One contract, every readiness probe. The same body runs against an
|
|
6
|
+
* in-memory probe and a live Postgres probe, so a difference between them
|
|
7
|
+
* shows up as a red test, not a surprise in production. Hand it the
|
|
8
|
+
* port's unavailable stand-in as a fourth argument and it walks the "not
|
|
9
|
+
* ready" branch too.
|
|
10
|
+
*
|
|
11
|
+
* What this contract cannot prove: that a real database falling silent
|
|
12
|
+
* is what makes the probe say no. A fake can only ever answer the way it
|
|
13
|
+
* was built to, and the two reasons a Postgres probe tells apart — a
|
|
14
|
+
* query that failed and a query that never came back —
|
|
15
|
+
* `src/postgres/readiness-probe.test.ts` owns, against a query it
|
|
16
|
+
* controls.
|
|
17
|
+
*/
|
|
18
|
+
export declare const readinessProbeContract: (it: PortContractTester, implementationName: string, layer: Layer.Layer<ReadinessProbe>, unavailable?: Layer.Layer<ReadinessProbe>) => void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect } from '@effect/vitest';
|
|
2
|
+
import { ReadinessProbe } from '@satorio/machinery/ports';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
/**
|
|
5
|
+
* One contract, every readiness probe. The same body runs against an
|
|
6
|
+
* in-memory probe and a live Postgres probe, so a difference between them
|
|
7
|
+
* shows up as a red test, not a surprise in production. Hand it the
|
|
8
|
+
* port's unavailable stand-in as a fourth argument and it walks the "not
|
|
9
|
+
* ready" branch too.
|
|
10
|
+
*
|
|
11
|
+
* What this contract cannot prove: that a real database falling silent
|
|
12
|
+
* is what makes the probe say no. A fake can only ever answer the way it
|
|
13
|
+
* was built to, and the two reasons a Postgres probe tells apart — a
|
|
14
|
+
* query that failed and a query that never came back —
|
|
15
|
+
* `src/postgres/readiness-probe.test.ts` owns, against a query it
|
|
16
|
+
* controls.
|
|
17
|
+
*/
|
|
18
|
+
export const readinessProbeContract = (it, implementationName, layer, unavailable) => {
|
|
19
|
+
const run = (effect) => Effect.provide(effect, layer);
|
|
20
|
+
describe(`ReadinessProbe contract (${implementationName})`, () => {
|
|
21
|
+
it.effect('a database that answers makes the probe succeed', () => run(Effect.gen(function* () {
|
|
22
|
+
const probe = yield* ReadinessProbe;
|
|
23
|
+
yield* probe.verifyDatabaseAnswers;
|
|
24
|
+
})));
|
|
25
|
+
it.effect('asking twice answers twice, and holds nothing back', () => run(Effect.gen(function* () {
|
|
26
|
+
const probe = yield* ReadinessProbe;
|
|
27
|
+
yield* probe.verifyDatabaseAnswers;
|
|
28
|
+
yield* probe.verifyDatabaseAnswers;
|
|
29
|
+
})));
|
|
30
|
+
});
|
|
31
|
+
if (unavailable === undefined)
|
|
32
|
+
return;
|
|
33
|
+
describe(`ReadinessProbe contract (${implementationName}, not mounted)`, () => {
|
|
34
|
+
it.effect('a database that stays silent says so, with a reason', () => Effect.gen(function* () {
|
|
35
|
+
const probe = yield* ReadinessProbe;
|
|
36
|
+
const failure = yield* Effect.flip(probe.verifyDatabaseAnswers);
|
|
37
|
+
expect(failure._tag).toBe('DatabaseDidNotAnswerError');
|
|
38
|
+
expect(failure.reason.length).toBeGreaterThan(0);
|
|
39
|
+
}).pipe(Effect.provide(unavailable)));
|
|
40
|
+
});
|
|
41
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TransactionBoundary } from '@satorio/machinery/ports';
|
|
2
|
+
import { type Layer } from 'effect';
|
|
3
|
+
import type { PortContractTester } from '../port-contract-tester.js';
|
|
4
|
+
/**
|
|
5
|
+
* One contract, every transaction boundary. The same body runs against an
|
|
6
|
+
* in-memory boundary and a live Postgres transaction, so a difference
|
|
7
|
+
* between them shows up as a red test, not a surprise in production.
|
|
8
|
+
* Hand it the port's unavailable stand-in as a fourth argument and it
|
|
9
|
+
* walks the refusal too: the work never runs, and the failure reaches
|
|
10
|
+
* the caller.
|
|
11
|
+
*
|
|
12
|
+
* What this contract cannot prove: that what the work wrote is what gets
|
|
13
|
+
* rolled back. An in-memory boundary restores the `Ref`s it was handed as
|
|
14
|
+
* participants; a Postgres one rolls back a real transaction and knows
|
|
15
|
+
* nothing about `Ref`s. Both promises are held here through a `Ref` the
|
|
16
|
+
* work updates and the boundary never sees, so the assertions read the
|
|
17
|
+
* same on both sides — and the Postgres half, where a row written inside
|
|
18
|
+
* a failed transaction is gone afterwards, is owned by
|
|
19
|
+
* `src/postgres/ports.postgres.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const transactionBoundaryContract: (it: PortContractTester, implementationName: string, layer: Layer.Layer<TransactionBoundary>, unavailable?: Layer.Layer<TransactionBoundary>) => void;
|