@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,72 @@
|
|
|
1
|
+
import { BunFileSystem } from '@effect/platform-bun';
|
|
2
|
+
import { SqlClient } from '@effect/sql';
|
|
3
|
+
import { PgClient } from '@effect/sql-pg';
|
|
4
|
+
import { DocumentNumberCounter, IntegrationEventOutbox, ProcessedEventRegistry, ReadinessProbe, TransactionBoundary, } from '@satorio/machinery/ports';
|
|
5
|
+
import { Effect, Layer } from 'effect';
|
|
6
|
+
import { poolOptionsFor, scopedPostgresTransport, sslOptionsFor, } from './client.js';
|
|
7
|
+
import { makePgDocumentNumberCounter } from './document-number-counter.js';
|
|
8
|
+
import { makePgIntegrationEventOutbox } from './integration-event-outbox.js';
|
|
9
|
+
import { MIGRATION_APPLICATION_NAME_SUFFIX, migrationPoolOptionsFor, } from './migrations/runner.js';
|
|
10
|
+
import { makePgProcessedEventRegistry } from './processed-event-registry.js';
|
|
11
|
+
import { readinessProbeAsking } from './readiness-probe.js';
|
|
12
|
+
import { postgresConnectionSettings } from './settings.js';
|
|
13
|
+
import { makePgTransactionBoundary } from './transaction-boundary.js';
|
|
14
|
+
const acquireDatabasePool = scopedPostgresTransport(poolOptionsFor, 'Could not open a connection pool to the database');
|
|
15
|
+
const acquireMigrationPool = scopedPostgresTransport(migrationPoolOptionsFor, 'Could not open a connection pool for the migration runner');
|
|
16
|
+
/**
|
|
17
|
+
* The `@effect/sql-pg` client every other layer here builds on, opened
|
|
18
|
+
* from the environment `settings.ts` names.
|
|
19
|
+
*/
|
|
20
|
+
export const PostgresClientLive = Layer.unwrapEffect(Effect.gen(function* () {
|
|
21
|
+
const settings = yield* postgresConnectionSettings;
|
|
22
|
+
const ssl = yield* sslOptionsFor(settings);
|
|
23
|
+
return PgClient.layerFromPool({
|
|
24
|
+
acquire: acquireDatabasePool(settings, ssl),
|
|
25
|
+
applicationName: settings.applicationName,
|
|
26
|
+
});
|
|
27
|
+
}));
|
|
28
|
+
/**
|
|
29
|
+
* The client the migration runner uses: one connection, under its own name.
|
|
30
|
+
*
|
|
31
|
+
* The same client for the migration runner: one connection, and its own name
|
|
32
|
+
* in `pg_stat_activity`, so a long migration is easy to spot next to the
|
|
33
|
+
* service's own pool.
|
|
34
|
+
*/
|
|
35
|
+
export const MigrationPgClientLive = Layer.unwrapEffect(Effect.gen(function* () {
|
|
36
|
+
const settings = yield* postgresConnectionSettings;
|
|
37
|
+
const ssl = yield* sslOptionsFor(settings);
|
|
38
|
+
return PgClient.layerFromPool({
|
|
39
|
+
acquire: acquireMigrationPool(settings, ssl),
|
|
40
|
+
applicationName: `${settings.applicationName}${MIGRATION_APPLICATION_NAME_SUFFIX}`,
|
|
41
|
+
});
|
|
42
|
+
}));
|
|
43
|
+
/**
|
|
44
|
+
* The transaction boundary on a real Postgres transaction.
|
|
45
|
+
*/
|
|
46
|
+
export const PgTransactionBoundaryLive = Layer.effect(TransactionBoundary, makePgTransactionBoundary);
|
|
47
|
+
/**
|
|
48
|
+
* The exactly-once claim on a real `processed_event` table.
|
|
49
|
+
*/
|
|
50
|
+
export const PgProcessedEventRegistryLive = Layer.effect(ProcessedEventRegistry, makePgProcessedEventRegistry);
|
|
51
|
+
/**
|
|
52
|
+
* The document number counter on a real `document_number_counter` table.
|
|
53
|
+
*/
|
|
54
|
+
export const PgDocumentNumberCounterLive = Layer.effect(DocumentNumberCounter, makePgDocumentNumberCounter);
|
|
55
|
+
/**
|
|
56
|
+
* The outbox on a real `integration_event_outbox` table.
|
|
57
|
+
*/
|
|
58
|
+
export const PgIntegrationEventOutboxLive = Layer.effect(IntegrationEventOutbox, makePgIntegrationEventOutbox);
|
|
59
|
+
/**
|
|
60
|
+
* The readiness probe on one `SELECT 1` against the service's own pool.
|
|
61
|
+
*/
|
|
62
|
+
export const PgReadinessProbeLive = Layer.effect(ReadinessProbe, Effect.map(SqlClient.SqlClient, (sql) => readinessProbeAsking(sql `SELECT 1`)));
|
|
63
|
+
/**
|
|
64
|
+
* Every port this mechanism implements, on one client, for a service
|
|
65
|
+
* that supplies its own `FileSystem`.
|
|
66
|
+
*/
|
|
67
|
+
export const PostgresPortsLive = Layer.mergeAll(PgProcessedEventRegistryLive, PgIntegrationEventOutboxLive, PgDocumentNumberCounterLive, PgTransactionBoundaryLive, PgReadinessProbeLive).pipe(Layer.provideMerge(PostgresClientLive));
|
|
68
|
+
/**
|
|
69
|
+
* The same bundle on Bun's own file system, which is the whole Postgres
|
|
70
|
+
* side of the machinery in one layer.
|
|
71
|
+
*/
|
|
72
|
+
export const PostgresPortsOnBunLive = PostgresPortsLive.pipe(Layer.provide(BunFileSystem.layer));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SqlClient, type SqlError } from '@effect/sql';
|
|
2
|
+
import { Effect, Schedule } from 'effect';
|
|
3
|
+
import { MigrationLockBusyError } from '../failures.js';
|
|
4
|
+
/**
|
|
5
|
+
* The lowest int4 Postgres accepts, which is the floor of both halves of
|
|
6
|
+
* an advisory-lock key.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export declare const POSTGRES_INTEGER_MIN = -2147483648;
|
|
11
|
+
/**
|
|
12
|
+
* The highest int4 Postgres accepts, which is the ceiling of both halves
|
|
13
|
+
* of an advisory-lock key.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export declare const POSTGRES_INTEGER_MAX = 2147483647;
|
|
18
|
+
/**
|
|
19
|
+
* A lock somebody else may already hold: its name, the attempt to take
|
|
20
|
+
* it, and the release that follows.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export interface AdvisoryLock<E, R> {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly tryAcquire: Effect.Effect<boolean, E, R>;
|
|
27
|
+
readonly release: Effect.Effect<void, E, R>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Turns a lock name into the pair of int4 keys Postgres advisory locks
|
|
31
|
+
* need. Two different hashes of the same name form the two halves, so a
|
|
32
|
+
* collision needs both halves to match.
|
|
33
|
+
*
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export declare const advisoryLockKeyFor: (name: string) => readonly [number, number];
|
|
37
|
+
/**
|
|
38
|
+
* Runs an effect while holding the lock, waiting on the given schedule
|
|
39
|
+
* while somebody else holds it, and letting go however the effect ends.
|
|
40
|
+
*/
|
|
41
|
+
export declare const holdingAdvisoryLock: <ELock, RLock, ScheduleOut, RSchedule>(lock: AdvisoryLock<ELock, RLock>, retryWhileBusy: Schedule.Schedule<ScheduleOut, unknown, RSchedule>) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | ELock | MigrationLockBusyError, R | RLock | RSchedule>;
|
|
42
|
+
/**
|
|
43
|
+
* A named advisory lock on the connection's own database. The name is
|
|
44
|
+
* hashed into the pair of int4 keys Postgres wants, so two names collide
|
|
45
|
+
* only when both halves do.
|
|
46
|
+
*/
|
|
47
|
+
export declare const postgresAdvisoryLock: (name: string) => AdvisoryLock<SqlError.SqlError, SqlClient.SqlClient>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { SqlClient } from '@effect/sql';
|
|
2
|
+
import { Effect, Schedule } from 'effect';
|
|
3
|
+
import { MigrationLockBusyError } from '../failures.js';
|
|
4
|
+
/**
|
|
5
|
+
* The lowest int4 Postgres accepts, which is the floor of both halves of
|
|
6
|
+
* an advisory-lock key.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const POSTGRES_INTEGER_MIN = -2_147_483_648;
|
|
11
|
+
/**
|
|
12
|
+
* The highest int4 Postgres accepts, which is the ceiling of both halves
|
|
13
|
+
* of an advisory-lock key.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
|
18
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
19
|
+
const FNV_PRIME = 0x01000193;
|
|
20
|
+
const LOWER_HALF_SALT = 'postgres-advisory-lock';
|
|
21
|
+
const fnv1a32 = (text) => {
|
|
22
|
+
let hash = FNV_OFFSET_BASIS;
|
|
23
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
24
|
+
hash = Math.imul(hash ^ text.charCodeAt(index), FNV_PRIME);
|
|
25
|
+
}
|
|
26
|
+
return hash | 0;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Turns a lock name into the pair of int4 keys Postgres advisory locks
|
|
30
|
+
* need. Two different hashes of the same name form the two halves, so a
|
|
31
|
+
* collision needs both halves to match.
|
|
32
|
+
*
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export const advisoryLockKeyFor = (name) => [
|
|
36
|
+
fnv1a32(name),
|
|
37
|
+
fnv1a32(`${LOWER_HALF_SALT}:${name}`),
|
|
38
|
+
];
|
|
39
|
+
const errorIsLockBusy = (error) => error instanceof MigrationLockBusyError;
|
|
40
|
+
/**
|
|
41
|
+
* Runs an effect while holding the lock, waiting on the given schedule
|
|
42
|
+
* while somebody else holds it, and letting go however the effect ends.
|
|
43
|
+
*/
|
|
44
|
+
export const holdingAdvisoryLock = (lock, retryWhileBusy) => (effect) => Effect.acquireUseRelease(Effect.retry(Effect.flatMap(lock.tryAcquire, (acquired) => acquired
|
|
45
|
+
? Effect.void
|
|
46
|
+
: Effect.fail(new MigrationLockBusyError({ lockName: lock.name }))), Schedule.whileInput(retryWhileBusy, errorIsLockBusy)), () => effect, () => Effect.orDie(lock.release));
|
|
47
|
+
/**
|
|
48
|
+
* A named advisory lock on the connection's own database. The name is
|
|
49
|
+
* hashed into the pair of int4 keys Postgres wants, so two names collide
|
|
50
|
+
* only when both halves do.
|
|
51
|
+
*/
|
|
52
|
+
export const postgresAdvisoryLock = (name) => {
|
|
53
|
+
const [upperHalf, lowerHalf] = advisoryLockKeyFor(name);
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
tryAcquire: Effect.flatMap(SqlClient.SqlClient, (sql) => sql `SELECT pg_try_advisory_lock(${upperHalf}, ${lowerHalf}) AS acquired`.pipe(Effect.map((rows) => rows[0]?.acquired === true))),
|
|
57
|
+
release: Effect.flatMap(SqlClient.SqlClient, (sql) => Effect.asVoid(sql `SELECT pg_advisory_unlock(${upperHalf}, ${lowerHalf})`)),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ConnectionOptions } from 'node:tls';
|
|
2
|
+
import { Migrator, SqlClient } from '@effect/sql';
|
|
3
|
+
import type { SqlError } from '@effect/sql/SqlError';
|
|
4
|
+
import { Effect, Schedule } from 'effect';
|
|
5
|
+
import type { PoolConfig } from 'pg';
|
|
6
|
+
import { type PostgresConnectionSettings } from '../settings.js';
|
|
7
|
+
/**
|
|
8
|
+
* Directory holding the machinery's shared migrations: the processed-event
|
|
9
|
+
* registry and the outbox. The machinery reserves numbers 1-99; a service's
|
|
10
|
+
* own migrations start at 100.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MACHINERY_MIGRATIONS_DIRECTORY: string;
|
|
13
|
+
/**
|
|
14
|
+
* What the migration runner adds to the application name, so a long
|
|
15
|
+
* migration is easy to tell from the service's own pool in
|
|
16
|
+
* `pg_stat_activity`.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export declare const MIGRATION_APPLICATION_NAME_SUFFIX = "-migrate";
|
|
21
|
+
/**
|
|
22
|
+
* The name every migration lock is built from, so one reader finds them all.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MIGRATION_LOCK_NAME = "migrations";
|
|
25
|
+
/**
|
|
26
|
+
* The lock one schema's migrations take, so two services migrating two
|
|
27
|
+
* schemas never wait on each other.
|
|
28
|
+
*/
|
|
29
|
+
export declare const migrationLockNameFor: (schema: string) => string;
|
|
30
|
+
/**
|
|
31
|
+
* Retry schedule for the migration advisory lock. A second process does
|
|
32
|
+
* not fail or run in parallel: it waits up to five minutes for the first
|
|
33
|
+
* process to finish.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIGRATION_LOCK_RETRY: Schedule.Schedule<[number, number], unknown, never>;
|
|
38
|
+
/**
|
|
39
|
+
* Builds pool options for the migration runner: one process, one
|
|
40
|
+
* connection. Migrations run one after another, so a pool of two would
|
|
41
|
+
* only confuse the picture in `pg_stat_activity`.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
export declare const migrationPoolOptionsFor: (settings: PostgresConnectionSettings, ssl: boolean | ConnectionOptions) => PoolConfig;
|
|
46
|
+
/**
|
|
47
|
+
* Applies pending migrations from the given directories, the machinery's
|
|
48
|
+
* own directory first, then the caller's. Creates the schema before
|
|
49
|
+
* running any migration, since the first migration already writes to it.
|
|
50
|
+
*/
|
|
51
|
+
export declare const applyMigrations: (directories?: readonly string[]) => Effect.Effect<readonly (readonly [id: number, name: string])[], import("effect/ConfigError").ConfigError | SqlError | import("..").MigrationLockBusyError | Migrator.MigrationError, import("@effect/platform/FileSystem").FileSystem | SqlClient.SqlClient>;
|
|
52
|
+
/**
|
|
53
|
+
* Says in one line what the run applied, or that the database was
|
|
54
|
+
* already current.
|
|
55
|
+
*/
|
|
56
|
+
export declare const reportApplied: (applied: ReadonlyArray<readonly [number, string]>) => Effect.Effect<void>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Migrator, SqlClient } from '@effect/sql';
|
|
2
|
+
import { Duration, Effect, Schedule } from 'effect';
|
|
3
|
+
import { poolOptionsFor } from '../client.js';
|
|
4
|
+
import { postgresConnectionSettings, } from '../settings.js';
|
|
5
|
+
import { holdingAdvisoryLock, postgresAdvisoryLock } from './advisory-lock.js';
|
|
6
|
+
import { sqlMigrationsIn } from './sql-file-loader.js';
|
|
7
|
+
/**
|
|
8
|
+
* Directory holding the machinery's shared migrations: the processed-event
|
|
9
|
+
* registry and the outbox. The machinery reserves numbers 1-99; a service's
|
|
10
|
+
* own migrations start at 100.
|
|
11
|
+
*/
|
|
12
|
+
export const MACHINERY_MIGRATIONS_DIRECTORY = `${import.meta.dirname}/../sql`;
|
|
13
|
+
/**
|
|
14
|
+
* What the migration runner adds to the application name, so a long
|
|
15
|
+
* migration is easy to tell from the service's own pool in
|
|
16
|
+
* `pg_stat_activity`.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export const MIGRATION_APPLICATION_NAME_SUFFIX = '-migrate';
|
|
21
|
+
/**
|
|
22
|
+
* The name every migration lock is built from, so one reader finds them all.
|
|
23
|
+
*/
|
|
24
|
+
export const MIGRATION_LOCK_NAME = 'migrations';
|
|
25
|
+
/**
|
|
26
|
+
* The lock one schema's migrations take, so two services migrating two
|
|
27
|
+
* schemas never wait on each other.
|
|
28
|
+
*/
|
|
29
|
+
export const migrationLockNameFor = (schema) => `${MIGRATION_LOCK_NAME}:${schema}`;
|
|
30
|
+
/**
|
|
31
|
+
* Retry schedule for the migration advisory lock. A second process does
|
|
32
|
+
* not fail or run in parallel: it waits up to five minutes for the first
|
|
33
|
+
* process to finish.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export const MIGRATION_LOCK_RETRY = Schedule.spaced(Duration.seconds(2)).pipe(Schedule.intersect(Schedule.recurs(150)));
|
|
38
|
+
/**
|
|
39
|
+
* Builds pool options for the migration runner: one process, one
|
|
40
|
+
* connection. Migrations run one after another, so a pool of two would
|
|
41
|
+
* only confuse the picture in `pg_stat_activity`.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
export const migrationPoolOptionsFor = (settings, ssl) => ({
|
|
46
|
+
...poolOptionsFor(settings, ssl),
|
|
47
|
+
application_name: `${settings.applicationName}${MIGRATION_APPLICATION_NAME_SUFFIX}`,
|
|
48
|
+
max: 1,
|
|
49
|
+
min: 1,
|
|
50
|
+
});
|
|
51
|
+
const ensureSchemaExists = (schema) => Effect.flatMap(SqlClient.SqlClient, (sql) => Effect.asVoid(sql `CREATE SCHEMA IF NOT EXISTS ${sql(schema)}`));
|
|
52
|
+
/**
|
|
53
|
+
* Applies pending migrations from the given directories, the machinery's
|
|
54
|
+
* own directory first, then the caller's. Creates the schema before
|
|
55
|
+
* running any migration, since the first migration already writes to it.
|
|
56
|
+
*/
|
|
57
|
+
export const applyMigrations = (directories = [MACHINERY_MIGRATIONS_DIRECTORY]) => Effect.flatMap(postgresConnectionSettings, (settings) => Effect.gen(function* () {
|
|
58
|
+
yield* ensureSchemaExists(settings.schema);
|
|
59
|
+
return yield* Migrator.make({})({
|
|
60
|
+
loader: sqlMigrationsIn(directories),
|
|
61
|
+
});
|
|
62
|
+
}).pipe(holdingAdvisoryLock(postgresAdvisoryLock(migrationLockNameFor(settings.schema)), MIGRATION_LOCK_RETRY)));
|
|
63
|
+
/**
|
|
64
|
+
* Says in one line what the run applied, or that the database was
|
|
65
|
+
* already current.
|
|
66
|
+
*/
|
|
67
|
+
export const reportApplied = (applied) => applied.length === 0
|
|
68
|
+
? Effect.logInfo('Migrations: nothing new, the database is already current')
|
|
69
|
+
: Effect.logInfo(`Migrations applied: ${applied.map(([id, name]) => `${id}_${name}`).join(', ')}`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { FileSystem } from '@effect/platform';
|
|
2
|
+
import { Migrator, SqlClient, type SqlError } from '@effect/sql';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
/**
|
|
5
|
+
* What a migration file is called: a number, an underscore, a name, and
|
|
6
|
+
* `.sql`.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export declare const SQL_MIGRATION_FILE_NAME: RegExp;
|
|
11
|
+
/**
|
|
12
|
+
* One migration's body, ready to run against the client.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export type SqlMigrationExecution = Effect.Effect<ReadonlyArray<object>, SqlError.SqlError, SqlClient.SqlClient>;
|
|
17
|
+
/**
|
|
18
|
+
* One migration in the shape `@effect/sql`'s migrator loads.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export type ResolvedSqlMigration = readonly [
|
|
23
|
+
id: number,
|
|
24
|
+
name: string,
|
|
25
|
+
load: Effect.Effect<SqlMigrationExecution>
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* One migration found on disk, with the body already read.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export interface DiscoveredSqlMigration {
|
|
33
|
+
readonly id: number;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly fileName: string;
|
|
36
|
+
readonly body: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Discovers migrations across one or more directories: the machinery's own
|
|
40
|
+
* shared tables and a service's own. The migration number must be unique
|
|
41
|
+
* across all directories, or the apply order would differ between
|
|
42
|
+
* environments — the machinery holds numbers 1-99, a service starts at 100.
|
|
43
|
+
*
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
export declare const discoverSqlMigrationsIn: (directories: readonly string[]) => Effect.Effect<ReadonlyArray<DiscoveredSqlMigration>, Migrator.MigrationError, FileSystem.FileSystem>;
|
|
47
|
+
/**
|
|
48
|
+
* Runs one migration body as a single statement batch.
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
export declare const executeSqlBody: (body: string) => SqlMigrationExecution;
|
|
53
|
+
/**
|
|
54
|
+
* The loader `@effect/sql`'s migrator takes: every migration of every
|
|
55
|
+
* directory, oldest number first.
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
export declare const sqlMigrationsIn: (directories: readonly string[]) => Effect.Effect<ReadonlyArray<ResolvedSqlMigration>, Migrator.MigrationError, FileSystem.FileSystem>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { FileSystem } from '@effect/platform';
|
|
2
|
+
import { Migrator, SqlClient } from '@effect/sql';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
/**
|
|
5
|
+
* What a migration file is called: a number, an underscore, a name, and
|
|
6
|
+
* `.sql`.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const SQL_MIGRATION_FILE_NAME = /^(\d+)_([^.]+)\.sql$/;
|
|
11
|
+
const directoryIsUnreadable = (directory, cause) => new Migrator.MigrationError({
|
|
12
|
+
cause,
|
|
13
|
+
reason: 'failed',
|
|
14
|
+
message: `Migration directory ${directory} is unreadable`,
|
|
15
|
+
});
|
|
16
|
+
const fileIsUnreadable = (path, cause) => new Migrator.MigrationError({
|
|
17
|
+
cause,
|
|
18
|
+
reason: 'failed',
|
|
19
|
+
message: `Migration file ${path} is unreadable`,
|
|
20
|
+
});
|
|
21
|
+
const fileNameBreaksTheNamingRule = (fileName) => new Migrator.MigrationError({
|
|
22
|
+
reason: 'failed',
|
|
23
|
+
message: `File ${fileName} sits in the migrations directory but isn't a ` +
|
|
24
|
+
`migration: the name must look like <number>_<name>.sql`,
|
|
25
|
+
});
|
|
26
|
+
const migrationNumberIsNotUnique = (clashes) => new Migrator.MigrationError({
|
|
27
|
+
reason: 'duplicates',
|
|
28
|
+
message: `Migration number isn't unique, no migration was applied: ` +
|
|
29
|
+
clashes
|
|
30
|
+
.map(([id, fileNames]) => `${id} - ${fileNames.join(', ')}`)
|
|
31
|
+
.join('; '),
|
|
32
|
+
});
|
|
33
|
+
const nameFileOrRefuse = (directory, fileName) => {
|
|
34
|
+
const parts = SQL_MIGRATION_FILE_NAME.exec(fileName);
|
|
35
|
+
const number = parts?.[1];
|
|
36
|
+
const name = parts?.[2];
|
|
37
|
+
return number === undefined || name === undefined
|
|
38
|
+
? Effect.fail(fileNameBreaksTheNamingRule(`${directory}/${fileName}`))
|
|
39
|
+
: Effect.succeed({
|
|
40
|
+
id: Number(number),
|
|
41
|
+
name,
|
|
42
|
+
fileName: `${directory}/${fileName}`,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const refuseRepeatedMigrationNumbers = (migrations) => {
|
|
46
|
+
const fileNamesByNumber = new Map();
|
|
47
|
+
for (const migration of migrations) {
|
|
48
|
+
fileNamesByNumber.set(migration.id, [
|
|
49
|
+
...(fileNamesByNumber.get(migration.id) ?? []),
|
|
50
|
+
migration.fileName,
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
const clashes = [...fileNamesByNumber.entries()]
|
|
54
|
+
.filter(([, fileNames]) => fileNames.length > 1)
|
|
55
|
+
.map(([id, fileNames]) => [id, [...fileNames].sort()]);
|
|
56
|
+
return clashes.length === 0
|
|
57
|
+
? Effect.void
|
|
58
|
+
: Effect.fail(migrationNumberIsNotUnique(clashes));
|
|
59
|
+
};
|
|
60
|
+
const byAscendingMigrationNumber = (left, right) => left.id - right.id;
|
|
61
|
+
const namedFilesIn = (directory) => Effect.gen(function* () {
|
|
62
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
63
|
+
const entries = yield* fileSystem
|
|
64
|
+
.readDirectory(directory)
|
|
65
|
+
.pipe(Effect.mapError((cause) => directoryIsUnreadable(directory, cause)));
|
|
66
|
+
return yield* Effect.forEach(entries, (fileName) => nameFileOrRefuse(directory, fileName));
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* Discovers migrations across one or more directories: the machinery's own
|
|
70
|
+
* shared tables and a service's own. The migration number must be unique
|
|
71
|
+
* across all directories, or the apply order would differ between
|
|
72
|
+
* environments — the machinery holds numbers 1-99, a service starts at 100.
|
|
73
|
+
*
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
export const discoverSqlMigrationsIn = (directories) => Effect.gen(function* () {
|
|
77
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
78
|
+
const perDirectory = yield* Effect.forEach(directories, namedFilesIn);
|
|
79
|
+
const named = perDirectory.flat();
|
|
80
|
+
yield* refuseRepeatedMigrationNumbers(named);
|
|
81
|
+
return yield* Effect.forEach([...named].sort(byAscendingMigrationNumber), (migration) => fileSystem.readFileString(migration.fileName).pipe(Effect.mapError((cause) => fileIsUnreadable(migration.fileName, cause)), Effect.map((body) => ({ ...migration, body }))));
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* Runs one migration body as a single statement batch.
|
|
85
|
+
*
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
export const executeSqlBody = (body) => Effect.flatMap(SqlClient.SqlClient, (sql) => sql.unsafe(body));
|
|
89
|
+
/**
|
|
90
|
+
* The loader `@effect/sql`'s migrator takes: every migration of every
|
|
91
|
+
* directory, oldest number first.
|
|
92
|
+
*
|
|
93
|
+
* @internal
|
|
94
|
+
*/
|
|
95
|
+
export const sqlMigrationsIn = (directories) => Effect.map(discoverSqlMigrationsIn(directories), (migrations) => migrations.map((migration) => [
|
|
96
|
+
migration.id,
|
|
97
|
+
migration.name,
|
|
98
|
+
Effect.succeed(executeSqlBody(migration.body)),
|
|
99
|
+
]));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PgClient } from '@effect/sql-pg';
|
|
2
|
+
import { type ClaimProcessedEventInput, ProcessedEventRegistryUnavailableError } from '@satorio/machinery/ports';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the registry on the service's own pool: one statement that both
|
|
6
|
+
* takes the claim and reads who won it, retried only over the race
|
|
7
|
+
* window.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export declare const makePgProcessedEventRegistry: Effect.Effect<{
|
|
12
|
+
claim: (input: ClaimProcessedEventInput) => Effect.Effect<import("@satorio/machinery/ports").ProcessedEventClaim, ProcessedEventRegistryUnavailableError>;
|
|
13
|
+
}, never, PgClient.PgClient>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PgClient } from '@effect/sql-pg';
|
|
2
|
+
import { alreadyProcessed, claimAccepted, ProcessedEventRegistryUnavailableError, portUnavailable, } from '@satorio/machinery/ports';
|
|
3
|
+
import { DateTime, Effect, Schedule } from 'effect';
|
|
4
|
+
import { ClaimRaceWindowMissedError } from './failures.js';
|
|
5
|
+
const CLAIM_RACE_WINDOW_RETRY_LIMIT = 5;
|
|
6
|
+
const RETRY_ONLY_ON_RACE_WINDOW_MISS = Schedule.recurs(CLAIM_RACE_WINDOW_RETRY_LIMIT).pipe(Schedule.whileInput((failure) => failure._tag === 'ClaimRaceWindowMissedError'));
|
|
7
|
+
/**
|
|
8
|
+
* Builds the registry on the service's own pool: one statement that both
|
|
9
|
+
* takes the claim and reads who won it, retried only over the race
|
|
10
|
+
* window.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export const makePgProcessedEventRegistry = Effect.gen(function* () {
|
|
15
|
+
const sql = yield* PgClient.PgClient;
|
|
16
|
+
const unavailable = portUnavailable(ProcessedEventRegistryUnavailableError);
|
|
17
|
+
const oneClaimAttempt = (input) => Effect.gen(function* () {
|
|
18
|
+
const outcome = yield* sql `
|
|
19
|
+
WITH accepted AS (
|
|
20
|
+
INSERT INTO processed_event (event_id, processed_at)
|
|
21
|
+
VALUES (${input.eventId}::text, ${DateTime.toDate(input.processedAt)}::timestamptz)
|
|
22
|
+
ON CONFLICT (event_id) DO NOTHING
|
|
23
|
+
RETURNING processed_at AS "processedAt", true AS "claimed"
|
|
24
|
+
)
|
|
25
|
+
SELECT "processedAt", "claimed" FROM accepted
|
|
26
|
+
UNION ALL
|
|
27
|
+
SELECT processed_at AS "processedAt", false AS "claimed"
|
|
28
|
+
FROM processed_event
|
|
29
|
+
WHERE event_id = ${input.eventId}::text
|
|
30
|
+
AND NOT EXISTS (SELECT 1 FROM accepted)
|
|
31
|
+
`;
|
|
32
|
+
const row = outcome[0];
|
|
33
|
+
return row === undefined
|
|
34
|
+
? yield* new ClaimRaceWindowMissedError({ eventId: input.eventId })
|
|
35
|
+
: row;
|
|
36
|
+
});
|
|
37
|
+
const claim = (input) => oneClaimAttempt(input).pipe(Effect.retry(RETRY_ONLY_ON_RACE_WINDOW_MISS), Effect.map((row) => row.claimed
|
|
38
|
+
? claimAccepted
|
|
39
|
+
: alreadyProcessed(DateTime.unsafeMake(row.processedAt))), Effect.catchTags({
|
|
40
|
+
SqlError: unavailable('claim'),
|
|
41
|
+
ClaimRaceWindowMissedError: (failure) => unavailable('claim')(`Race window did not close within ${CLAIM_RACE_WINDOW_RETRY_LIMIT} attempts for eventId ${failure.eventId}`),
|
|
42
|
+
}));
|
|
43
|
+
return { claim };
|
|
44
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SqlError } from '@effect/sql/SqlError';
|
|
2
|
+
import { DatabaseDidNotAnswerError } from '@satorio/machinery/ports';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
/**
|
|
5
|
+
* Reason reported when the readiness query answers with a failure. A
|
|
6
|
+
* probe reads `reason` to tell a silent database from a slow one.
|
|
7
|
+
*/
|
|
8
|
+
export declare const READINESS_REASON_WHEN_THE_DATABASE_STAYS_SILENT = "database did not answer the readiness query";
|
|
9
|
+
/**
|
|
10
|
+
* Reason reported when the readiness query times out. A database you can't
|
|
11
|
+
* reach doesn't always fail fast: the client may keep trying to connect
|
|
12
|
+
* while the caller waits. Without a deadline the probe would hang instead
|
|
13
|
+
* of reporting "down", and `/readyz` would hang with it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const READINESS_REASON_WHEN_THE_QUERY_TIMES_OUT = "readiness query did not answer within the deadline";
|
|
16
|
+
/**
|
|
17
|
+
* How long the probe waits for its query before it reports "down".
|
|
18
|
+
*/
|
|
19
|
+
export declare const READINESS_QUERY_DEADLINE_MILLIS = 2000;
|
|
20
|
+
/**
|
|
21
|
+
* Builds a probe around one query the database must answer, with a
|
|
22
|
+
* deadline over it so an unreachable database reports "down" instead of
|
|
23
|
+
* hanging.
|
|
24
|
+
*/
|
|
25
|
+
export declare const readinessProbeAsking: (aQueryTheDatabaseMustAnswer: Effect.Effect<unknown, SqlError>) => {
|
|
26
|
+
verifyDatabaseAnswers: Effect.Effect<void, DatabaseDidNotAnswerError, never>;
|
|
27
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DatabaseDidNotAnswerError } from '@satorio/machinery/ports';
|
|
2
|
+
import { Duration, Effect } from 'effect';
|
|
3
|
+
/**
|
|
4
|
+
* Reason reported when the readiness query answers with a failure. A
|
|
5
|
+
* probe reads `reason` to tell a silent database from a slow one.
|
|
6
|
+
*/
|
|
7
|
+
export const READINESS_REASON_WHEN_THE_DATABASE_STAYS_SILENT = 'database did not answer the readiness query';
|
|
8
|
+
/**
|
|
9
|
+
* Reason reported when the readiness query times out. A database you can't
|
|
10
|
+
* reach doesn't always fail fast: the client may keep trying to connect
|
|
11
|
+
* while the caller waits. Without a deadline the probe would hang instead
|
|
12
|
+
* of reporting "down", and `/readyz` would hang with it.
|
|
13
|
+
*/
|
|
14
|
+
export const READINESS_REASON_WHEN_THE_QUERY_TIMES_OUT = 'readiness query did not answer within the deadline';
|
|
15
|
+
/**
|
|
16
|
+
* How long the probe waits for its query before it reports "down".
|
|
17
|
+
*/
|
|
18
|
+
export const READINESS_QUERY_DEADLINE_MILLIS = 2_000;
|
|
19
|
+
/**
|
|
20
|
+
* Builds a probe around one query the database must answer, with a
|
|
21
|
+
* deadline over it so an unreachable database reports "down" instead of
|
|
22
|
+
* hanging.
|
|
23
|
+
*/
|
|
24
|
+
export const readinessProbeAsking = (aQueryTheDatabaseMustAnswer) => ({
|
|
25
|
+
verifyDatabaseAnswers: aQueryTheDatabaseMustAnswer.pipe(Effect.asVoid, Effect.catchTag('SqlError', (silence) => Effect.logError('readiness query failed', silence).pipe(Effect.andThen(new DatabaseDidNotAnswerError({
|
|
26
|
+
reason: READINESS_REASON_WHEN_THE_DATABASE_STAYS_SILENT,
|
|
27
|
+
})))), Effect.timeoutFail({
|
|
28
|
+
duration: Duration.millis(READINESS_QUERY_DEADLINE_MILLIS),
|
|
29
|
+
onTimeout: () => new DatabaseDidNotAnswerError({
|
|
30
|
+
reason: READINESS_REASON_WHEN_THE_QUERY_TIMES_OUT,
|
|
31
|
+
}),
|
|
32
|
+
})),
|
|
33
|
+
});
|