diff --git a/capabilities/coordination/src/testing/test-host.ts b/capabilities/coordination/src/testing/test-host.ts index a2f0577d8..d40fbe535 100644 --- a/capabilities/coordination/src/testing/test-host.ts +++ b/capabilities/coordination/src/testing/test-host.ts @@ -1,8 +1,8 @@ import { shippedFilesOnDisk } from '@on.auto/operations/shipped-files-on-disk'; import { freshInstances, machineSandboxOf, sandboxModuleIn, sandboxShippedRoots } from '@on.auto/workflow-engine/dsl'; -import { openWorkflowHost, type WorkflowHost } from '@on.auto/workflow-host'; -import { recordedReactions, recordedWaiting } from '@on.auto/workflow-host/testing'; -import { Effect, Function } from 'effect'; +import type { WorkflowHost } from '@on.auto/workflow-host'; +import { recordedReactions, recordedWaiting, workflowHostOnALedger } from '@on.auto/workflow-host/testing'; +import { Effect } from 'effect'; import { callResultOfEnding, definitionCalls } from '../calls/function-calls.ts'; import { workflowMachineOptionsOf } from '../runs/workflow-machine-options.ts'; @@ -14,20 +14,21 @@ const sandboxFiles = shippedFilesOnDisk(sandboxShippedRoots); const sandboxModule = await sandboxModuleIn(sandboxFiles); export function testHost(nested: Playbook): Promise { - return openWorkflowHost({ - database: { store: 'sqlite', file: ':memory:' }, - machine: workflowMachineOptionsOf(machineSandboxOf(freshInstances(sandboxModule)), testPlaybookNames), - perform: definitionCalls(nested.executeNested), - settle: nested.settle, - reports: { - unsettled: Effect.logWarning, - trouble: Effect.logWarning, - lostConnection: Function.constVoid, - note: Effect.logWarning, + return workflowHostOnALedger( + { store: 'sqlite', file: ':memory:' }, + { + machine: workflowMachineOptionsOf(machineSandboxOf(freshInstances(sandboxModule)), testPlaybookNames), + perform: definitionCalls(nested.executeNested), + settle: nested.settle, + reports: { + unsettled: Effect.logWarning, + trouble: Effect.logWarning, + note: Effect.logWarning, + }, + sweepEveryMs: 20, + mostCallsAtOnce: 4, + reactions: recordedReactions().options, + waiting: { ...recordedWaiting().options, resultOf: callResultOfEnding }, }, - sweepEveryMs: 20, - mostCallsAtOnce: 4, - reactions: recordedReactions().options, - waiting: { ...recordedWaiting().options, resultOf: callResultOfEnding }, - }); + ); } diff --git a/capabilities/prediction/measure/dataset-rebuild.ts b/capabilities/prediction/measure/dataset-rebuild.ts index e1de6b4e7..0d3d5c644 100644 --- a/capabilities/prediction/measure/dataset-rebuild.ts +++ b/capabilities/prediction/measure/dataset-rebuild.ts @@ -1,8 +1,8 @@ import { setTimeout } from 'node:timers/promises'; import type { FilterSandbox } from '@on.auto/workflow-engine'; -import { someFolder, type DatabaseSettings, type HostDatabase } from '@on.auto/workflow-host'; -import { projectedFolders, type ProjectedFolders } from '@on.auto/workflow-host/testing'; +import { someFolder, type HostDatabase } from '@on.auto/workflow-host'; +import { projectedFolders, type DatabaseSettings, type ProjectedFolders } from '@on.auto/workflow-host/testing'; import { Effect } from 'effect'; import { datasetFolder, datasetsOf } from '../src/dataset/dataset-folder.ts'; diff --git a/capabilities/prediction/measure/stores.ts b/capabilities/prediction/measure/stores.ts index 0a4fa6195..6ab3ac93f 100644 --- a/capabilities/prediction/measure/stores.ts +++ b/capabilities/prediction/measure/stores.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { plainPoolsOn, postgresqlLedgerLayer } from '@on.auto/ledger/postgresql'; import { ledgerLayer } from '@on.auto/ledger/sqlite3'; import type { Ledger } from '@on.auto/operations'; -import type { DatabaseSettings } from '@on.auto/workflow-host'; +import type { DatabaseSettings } from '@on.auto/workflow-host/testing'; import type { Layer } from 'effect'; import { Client } from 'pg'; diff --git a/capabilities/prediction/src/dataset-testing/dataset-stores.ts b/capabilities/prediction/src/dataset-testing/dataset-stores.ts index 1bb4b870d..f33dad74c 100644 --- a/capabilities/prediction/src/dataset-testing/dataset-stores.ts +++ b/capabilities/prediction/src/dataset-testing/dataset-stores.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { DatabaseSettings } from '@on.auto/workflow-host'; +import type { DatabaseSettings } from '@on.auto/workflow-host/testing'; import { onTestFinished } from 'vitest'; export type SettingsOf = () => Promise; diff --git a/capabilities/prediction/src/dataset-testing/projected-datasets.ts b/capabilities/prediction/src/dataset-testing/projected-datasets.ts index 9f4121437..ef336f15b 100644 --- a/capabilities/prediction/src/dataset-testing/projected-datasets.ts +++ b/capabilities/prediction/src/dataset-testing/projected-datasets.ts @@ -1,7 +1,7 @@ import { shippedFilesOnDisk } from '@on.auto/operations/shipped-files-on-disk'; import { filterSandboxOf, freshInstances, sandboxModuleIn, sandboxShippedRoots } from '@on.auto/workflow-engine/dsl'; -import { someFolder, type DatabaseSettings } from '@on.auto/workflow-host'; -import { projectedFolders, type ProjectedFolders } from '@on.auto/workflow-host/testing'; +import { someFolder } from '@on.auto/workflow-host'; +import { projectedFolders, type DatabaseSettings, type ProjectedFolders } from '@on.auto/workflow-host/testing'; import { Effect, Option, Result, type Schema } from 'effect'; import { expect, onTestFinished, vi } from 'vitest'; diff --git a/docs/self-host/workflows.md b/docs/self-host/workflows.md index a7ba06015..a7dcd3614 100644 --- a/docs/self-host/workflows.md +++ b/docs/self-host/workflows.md @@ -144,7 +144,7 @@ The [engine's README](https://github.com/BeOnAuto/runtime/blob/main/packages/wor | `An answer of a call could not be recorded; it is written again until it is` | warning | The server keeps writing the call's result | | `A call could not record its answer` | warning | A call's result was not recorded | | `An answer of a call could not be given to its run` | warning | A recorded result did not reach its run | -| `A connection of the workflows to their PostgreSQL database was lost; they open another when they need one` | warning | PostgreSQL only; the server reconnects | +| `A connection to the PostgreSQL database of the ledger was lost; the ledger opens another when it needs one` | warning | PostgreSQL only; the workflows share the ledger's connections, and it reconnects | | `A run could not be settled in attempts; it is tried again once a minute until it is` | warning | The end of the run is retried once a minute; see [Settling a run](#settling-a-run) | | `A run that could not be settled was settled at attempt ` | warning | The end of the run was recorded | | `A run stays started because settling it failed` | error | `reason` is `The ledger has no such run` or `The run was settled otherwise before` | diff --git a/packages/ledger/README.md b/packages/ledger/README.md index 390016ab0..ccc7503f4 100644 --- a/packages/ledger/README.md +++ b/packages/ledger/README.md @@ -263,7 +263,7 @@ The ledger keeps content whole beside its messages rather than in them, since ev | `staged_content` | playbook key, run id, digest | the head of a content staged under a run | | `unstaged_runs` | playbook key, run id | the runs whose staging ended | -`content.put(playbook, { stream, mediaType, expected? })` gives the put an upload id of its own, a UUIDv7, and reads the stream through `takenInChunks` of `@on.auto/operations` into chunks of 1 MiB, digesting and measuring the bytes as they pass, writing each chunk under its upload once the next has filled, in a statement of its own and so on PostgreSQL in a transaction of its own, since every open write holds back the reads oldest first (see [Order, and the horizon on PostgreSQL](#order-and-the-horizon-on-postgresql)). At the end of the stream it knows the digest and the size, and `requireExpected` refuses a digest or a size other than the one `expected` names as `not_its_digest` or `not_its_size`, as a stream that fails is refused as `stream_failed`, each a `ContentRefused`. A content of one chunk is still in hand then, so when the playbook holds its digest the put writes nothing and answers the head it holds; a put whose expected digest the playbook holds reads its stream through the digest and writes nothing; otherwise the put writes its last chunk, then the head, naming its upload, with `ON CONFLICT DO NOTHING`, and reads the head back. The first head written for a digest stands: a put that finds the head of another upload, whether it raced another put of the digest or found the digest kept, deletes its own chunks and answers the head it found, its size and its first media type, so two puts of one digest never write over each other's chunks, a wrong put racing a right one cannot spoil it, and a second media type never replaces the first. A put that fails part way, refused, by its stream or by its store, deletes its own chunks too, and never chunks a head names. Chunks no head names can still remain: those of a process that stopped between a chunk and its head or before its delete, and on PostgreSQL a chunk whose insert was still on its way, on a connection of its own, when its put was interrupted and deleted its chunks. So the ledger sweeps them, on a schedule of its own that starts when it opens and stops when it closes: once at the start, without holding the open back, and then every hour, it deletes every chunk no head names whose upload began more than an hour before, by the time a UUIDv7 carries in its first 48 bits. The sweep is maintenance: a sweep that fails, the first one among them, is logged as an error and never stops the ledger, and the next runs an hour later. A close waits for a sweep under way to finish before it closes the store, so it never leaves the sweep's connection open or runs the sweep on a closed store. A ledger opened with a content store of its own, `content: { content, sweepContent }` among the options of `ledgerLayer` and `postgresqlLedgerLayer`, keeps its content there and sweeps that store on the same schedule, and its content tables stay empty; the server opens it so when `CONTENT_STORE_URL` names an object store, whose adapter, [`@on.auto/object-storage`](../object-storage/README.md), sweeps the uploads its puts left behind. A put that streams a request body ends within the 300 s Node gives a request, `server.requestTimeout` of the server's `createServer`, well within the hour. `content.get(playbook, sha256)` reads the head and answers `{ stream, bytes, mediaType }`, or nothing for a digest without a head. The stream reads one chunk of the head's upload each time it is pulled, so neither a put nor a get holds the whole content in memory, and digests what it reads through `verifiedStream` of `@on.auto/operations`, the check the object store's reads go through too: a chunk that is missing, or bytes that are not those the digest names, fail the stream as a defect, as the ledger fails on a fact that no longer decodes, and it hands over the last chunk only once the size and the digest are checked, so no reader takes every byte of a content that is not its digest's. The content is keyed by the playbook and the SHA-256 of its bytes, so the key is the content's own address; a text is kept as its UTF-8, so its digest is the SHA-256 of the text the fact names. The digest is Node's own, `createHash('sha256')`, through `sha256Digest` of `@on.auto/operations`. A chunk travels to the store as the hex of its bytes, through `unhex` on SQLite and `decode(…, 'hex')` on PostgreSQL, since the SQL builder binds a byte array as JSON. It comes back in the shape its driver answers, a `Uint8Array` or an `ArrayBuffer`, which the standard byte APIs take as the same bytes, and the read takes either through one function, `viewOf` in `src/content/content-places.ts`, which hands on a `Uint8Array` view of the bytes without copying them; `sqlite3` 6.0.1 and `pg` 8.23.1 both answer a Node `Buffer`, a `Uint8Array`. A fact carries a digest only when its content was kept, so a read never joins content to a fact that kept none. Nothing kept is deleted. +`content.put(playbook, { stream, mediaType, expected? })` gives the put an upload id of its own, a UUIDv7, and reads the stream through `takenInChunks` of `@on.auto/operations` into chunks of 1 MiB, digesting and measuring the bytes as they pass, writing each chunk under its upload once the next has filled, in a statement of its own and so on PostgreSQL in a transaction of its own, since every open write holds back the reads oldest first (see [Order, and the horizon on PostgreSQL](#order-and-the-horizon-on-postgresql)). At the end of the stream it knows the digest and the size, and `requireExpected` refuses a digest or a size other than the one `expected` names as `not_its_digest` or `not_its_size`, as a stream that fails is refused as `stream_failed`, each a `ContentRefused`. A content of one chunk is still in hand then, so when the playbook holds its digest the put writes nothing and answers the head it holds; a put whose expected digest the playbook holds reads its stream through the digest and writes nothing; otherwise the put writes its last chunk, then the head, naming its upload, with `ON CONFLICT DO NOTHING`, and reads the head back. The first head written for a digest stands: a put that finds the head of another upload, whether it raced another put of the digest or found the digest kept, deletes its own chunks and answers the head it found, its size and its first media type, so two puts of one digest never write over each other's chunks, a wrong put racing a right one cannot spoil it, and a second media type never replaces the first. A put that fails part way, refused, by its stream or by its store, deletes its own chunks too, and never chunks a head names. Chunks no head names can still remain: those of a process that stopped between a chunk and its head or before its delete, and on PostgreSQL a chunk whose insert was still on its way, on a connection of its own, when its put was interrupted and deleted its chunks. So the ledger sweeps them, as a chore on no clock of its own: both layers provide, beside `Ledger`, the chores of the ledger, `LedgerChores` of `@on.auto/ledger`, whose `sweepOrphans(now)` deletes every chunk no head names whose upload began more than an hour before `now`, by the time a UUIDv7 carries in its first 48 bits, and answers the next time it is due, an hour after `now`. Whatever runs the ledger calls it on its own clock, and nothing in the ledger forks a timer: the server's workflow host performs it in its wake-up, at the first and then whenever it is due (see [Due rows](../workflow-host/README.md#due-rows)). The sweep is maintenance: a sweep that fails is logged as an error, never fails the chore, and is due again an hour on. A close waits for a sweep under way to finish before it closes the store, so it never leaves the sweep's connection open or runs the sweep on a closed store. A ledger opened with a content store of its own, `content: { content, sweepContent }` among the options of `ledgerLayer` and `postgresqlLedgerLayer`, keeps its content there and its chore sweeps that store, and its content tables stay empty; the server opens it so when `CONTENT_STORE_URL` names an object store, whose adapter, [`@on.auto/object-storage`](../object-storage/README.md), sweeps the uploads its puts left behind. A put that streams a request body ends within the 300 s Node gives a request, `server.requestTimeout` of the server's `createServer`, well within the hour. `content.get(playbook, sha256)` reads the head and answers `{ stream, bytes, mediaType }`, or nothing for a digest without a head. The stream reads one chunk of the head's upload each time it is pulled, so neither a put nor a get holds the whole content in memory, and digests what it reads through `verifiedStream` of `@on.auto/operations`, the check the object store's reads go through too: a chunk that is missing, or bytes that are not those the digest names, fail the stream as a defect, as the ledger fails on a fact that no longer decodes, and it hands over the last chunk only once the size and the digest are checked, so no reader takes every byte of a content that is not its digest's. The content is keyed by the playbook and the SHA-256 of its bytes, so the key is the content's own address; a text is kept as its UTF-8, so its digest is the SHA-256 of the text the fact names. The digest is Node's own, `createHash('sha256')`, through `sha256Digest` of `@on.auto/operations`. A chunk travels to the store as the hex of its bytes, through `unhex` on SQLite and `decode(…, 'hex')` on PostgreSQL, since the SQL builder binds a byte array as JSON. It comes back in the shape its driver answers, a `Uint8Array` or an `ArrayBuffer`, which the standard byte APIs take as the same bytes, and the read takes either through one function, `viewOf` in `src/content/content-places.ts`, which hands on a `Uint8Array` view of the bytes without copying them; `sqlite3` 6.0.1 and `pg` 8.23.1 both answer a Node `Buffer`, a `Uint8Array`. A fact carries a digest only when its content was kept, so a read never joins content to a fact that kept none. Nothing kept is deleted. `content.stage(playbook, run, content)`, `content.staged(playbook, run, sha256)` and `content.unstage(playbook, run)` keep, read and remove content staged under a run, by its id, with the rules of a put and a get for the uploads, the heads and the digest, so a server reads what another staged on the same database. `unstage` records the run in `unstaged_runs` and then, in one transaction, removes the run's heads and then its chunks. The record stays, so `unstaged_runs` grows by one small row, the playbook key and the run id, for each run whose staging ended, as many as the runs that stage content; no capability of this repository stages content yet. A stage under a run already unstaged is refused with `StagingEnded` and leaves its stream unread, and a stage looks again once its head is written; if the run was unstaged meanwhile it deletes its own head and chunks and is refused the same way. Since the unstage records the run before it deletes and the stage writes its head before it looks, either the stage sees the record or the unstage's deletes see the head, so a stage that races an unstage leaves nothing behind. A read of a staged content whose run is unstaged while it is read fails at the first chunk it finds gone, as a defect, in words that say so: `The content staged under has no chunk , as when its run is unstaged while it is read`. @@ -298,7 +298,7 @@ Measured on 2026-10-12 on an Apple M4 Max with 16 cores, with Node 26.10.0, SQLi ## Kept secrets -The ledger keeps the rows of the secret store of [`@on.auto/secrets`](../secrets/README.md) apart from its facts and from its content: what the runtime must hold to act for someone, such as a key a person pasted or a token minted for them, which no fact of a stream may carry, since every reader of a stream reads its facts. It keeps the rows alone and seals nothing: a value reaches it as its keeper holds it, sealed under a data key of its own that the store's custodian wraps, as that package's README sets out under [Sealing](../secrets/README.md#sealing), or not at all, where a secret manager keeps it and the row holds the name and the version of the value there, as its README sets out under [The keeper](../secrets/README.md#the-keeper), so the ledger knows no key and neither its database nor a backup of it holds a value anyone can read without the custodian's or the manager's. The rows are not part of the `Ledger` port: `sqliteSecretRows({ fileName })` of `@on.auto/ledger/sqlite3` and `postgresqlSecretRows({ pools })` of `@on.auto/ledger/postgresql` each answer an `Effect` of the `SecretRows` port of `@on.auto/secrets`, opened on connections of their own from the settings the ledger is opened from, as the workflow host opens its own, once the ledger is open, as the server opens them. `sqliteSecretRowsOn(optionsOf)` of the main entry answers the same rows on any of Emmett's SQLite drivers, as `sqliteEventStore` opens the store on one, for code that opens the ledger on a driver of its own; `sqliteSecretRows` is it on `sqlite3`. On SQLite the rows migrate the ledger's schema on their own pool first, which finds the layout the ledger wrote and everything it made when it opened, and creates nothing, since a pool of its own on `:memory:` is a database of its own; on PostgreSQL they open a pool of two connections, from `pools` or any `(bounds) => SecretConnections`, on the database the ledger migrated, and log a connection they lose as the ledger logs one of its own. One table holds them, `kept_secrets`, created with `CREATE TABLE IF NOT EXISTS` by the same schema step that creates the content tables, on SQLite and on PostgreSQL, through the same dialect, and looked up first in the catalog as they are, so a start that finds it creates nothing: +The ledger keeps the rows of the secret store of [`@on.auto/secrets`](../secrets/README.md) apart from its facts and from its content: what the runtime must hold to act for someone, such as a key a person pasted or a token minted for them, which no fact of a stream may carry, since every reader of a stream reads its facts. It keeps the rows alone and seals nothing: a value reaches it as its keeper holds it, sealed under a data key of its own that the store's custodian wraps, as that package's README sets out under [Sealing](../secrets/README.md#sealing), or not at all, where a secret manager keeps it and the row holds the name and the version of the value there, as its README sets out under [The keeper](../secrets/README.md#the-keeper), so the ledger knows no key and neither its database nor a backup of it holds a value anyone can read without the custodian's or the manager's. The rows are not part of the `Ledger` port: `sqliteSecretRows({ fileName })` of `@on.auto/ledger/sqlite3` and `postgresqlSecretRows({ pools })` of `@on.auto/ledger/postgresql` each answer an `Effect` of the `SecretRows` port of `@on.auto/secrets`, opened on connections of their own from the settings the ledger is opened from, once the ledger is open, as the server opens them. `sqliteSecretRowsOn(optionsOf)` of the main entry answers the same rows on any of Emmett's SQLite drivers, as `sqliteEventStore` opens the store on one, for code that opens the ledger on a driver of its own; `sqliteSecretRows` is it on `sqlite3`. On SQLite the rows migrate the ledger's schema on their own pool first, which finds the layout the ledger wrote and everything it made when it opened, and creates nothing, since a pool of its own on `:memory:` is a database of its own; on PostgreSQL they open a pool of two connections, from `pools` or any `(bounds) => SecretConnections`, on the database the ledger migrated, and log a connection they lose as the ledger logs one of its own. One table holds them, `kept_secrets`, created with `CREATE TABLE IF NOT EXISTS` by the same schema step that creates the content tables, on SQLite and on PostgreSQL, through the same dialect, and looked up first in the catalog as they are, so a start that finds it creates nothing: | Column | What it holds | | --------------- | ---------------------------------------------------------------------------------------- | @@ -446,6 +446,16 @@ Building the layer connects to the database and creates or migrates Emmett's tab The database's user needs `CREATE` on the `public` schema at every start, not only the first: the ledger migrates at every start, and the migrator runs `CREATE TABLE IF NOT EXISTS` on its own table, which PostgreSQL checks for the privilege before it looks whether the table exists, so a user that may only read and write the tables fails to start with `permission denied for schema public`. It also needs to read and write the ledger's tables, and to create its functions and sequence on an empty database. +## The connection of the ledger + +Both layers provide, beside `Ledger`, the connection of the ledger, `LedgerConnection` of `@on.auto/ledger`, a `ConnectionPorts`, through which code in the same process keeps tables of its own in the ledger's database and appends streams of its own beside the ledger's, as the workflow host keeps its tables and its run logs (see the [workflow host README](../workflow-host/README.md#where-a-run-is-kept)), without opening the database again. The ledger opens it with its own store, when its layer is built, and closes it with its store, when its runtime is disposed; whoever runs its statements stops first. `LedgerServices` names the three, the chores of the ledger among them (see [Recorded content](#recorded-content)). + +- `dialect` is the SQL the database speaks, `sqlite` or `postgresql`, for the few statements that differ. +- `streams` is an event store over the ledger's own connections, a `LedgerStreams`, without the ledger's projections and run outcomes and outside the signal of its appends, so a stream appended there is recorded in the ledger's database, tells the `AppendObserver` the layer was given, raises `streamAppends`, and folds into no projection. +- `read(statement)` and `write(statement)` run one statement; `transaction(statements)` runs them in order in one transaction and keeps them only when the last writes a row, answering what the last answers; `readConsistently(reads)` gives the reads one snapshot of the database and keeps nothing; `migrated(statements)` makes tables and indexes, on PostgreSQL in one transaction under an advisory lock of its own, so that servers that open together make them once, and on SQLite one after another; `sharedClock()` reads the database's own clock in milliseconds on PostgreSQL, which every server on the database shares, and is `null` on SQLite, where one server holds the file. Each answers with the driver's error when the database refuses. + +A statement is a `Statement`, written with the tag `statement`, whose values are bound, never written into its text: on SQLite through the SQL builder of `@event-driven-io/dumbo`, and on PostgreSQL numbered `$1`, `$2` and so on in the order they appear, `textOnPostgreSQL`. On SQLite the connection runs on the ledger's one pool, its writer serialising every write of the ledger and of the code beside it, its snapshots on one of the pool's readers, or, in a private database in memory, on the pool's one connection, which holds every write until the snapshot ends. On PostgreSQL its statements run on a pool of at most four connections of their own and its streams on the ledger's pool of ten, both opened from `pools`, their lost connections logged as the ledger's own. `src/connection/sqlite-connection.test.ts` holds the connection to what it does on SQLite, and `src/postgresql/postgresql-connection.test.ts` to each statement it sends on PostgreSQL, through fake connections. + ## The layout of the ledger `ledgerLayout`, in `src/layout/ledger-layout.ts`, numbers the layout of every table and index the ledger makes itself: the playbook's indexes, the content tables and their indexes, `kept_secrets`, the table of each projection with its indexes, the outcomes of runs among them, and `ledger_layout`, the table that keeps the number. It is 2, and is raised by hand with any change to one of them. The tables Emmett and its database library make, whose names begin with `emt_` and `dmb_`, are theirs and outside the layout. @@ -496,7 +506,7 @@ Opened without the run outcome mapping and the projections, the ledger neither c ## Testing -The ledger's behaviour is one suite, in `src/testing/ledger-behaviour.ts`, that `src/ledger.test.ts` runs on SQLite and `src/postgresql/ledger-on-postgresql.test.ts` runs on PostgreSQL. `@on.auto/ledger/testing` exports it, `ledgerBehaviour(entry)`, with `LedgerEntry`, what a store hands the suite, so a ledger opened on a driver of its own runs the same suite. The parts of the SQLite entry that hang on SQLite's dialect rather than on its driver are exported from the main entry, which loads no driver: `sqliteLayout`, the `LayoutDialect` that lists the tables of the schema, `sqlitePlanOf(execute, read)`, the plans SQLite makes of the statements a read of a `RecordedStore` asks, and `sqliteDefinitionStreamsIndexed(execute)`, whether the read of the definition streams goes through its index, each over a `SQLExecutor` of dumbo that the caller opens on its driver (`src/recorded/sqlite-plans.ts`), beside the types the entry names, `LedgerStore`, `RecordedStore` and `StatementExecutor`. This package's own entry opens them on `sqlite3` (`src/recorded/sqlite-checks.ts`), and `src/ledger.test.ts` lists the indexes and the tables it expects SQLite to name. Its part on the layout of the ledger, `src/layout/ledger-layout-behaviour.ts`, runs on both stores: a database that holds no table given the layout and opened again in it, a database in another layout refused with the layout found and the layout kept, in the words of the refusal, and left as it was, and a database that holds a table but no layout number refused the same way and left as it was; `src/layout/ledger-layout.test.ts` pins the layout on SQLite (see [The layout of the ledger](#the-layout-of-the-ledger)), `src/layout/sqlite-schema.test.ts` checks without a database that SQLite lists the tables and writes the layout in one transaction, and `src/layout/sqlite-file-in-layout.test.ts` that `aSQLiteFileInLayout` of `@on.auto/ledger/testing`, with which the server's tests make a ledger of another version, puts a new file in the layout it is given. Its part on the outcomes of runs, `src/outcomes/run-outcomes-behaviour.ts`, also runs on the in-memory ledger, in `src/outcomes/run-outcomes-in-memory.test.ts`, with the `runTallies` mapping of `@on.auto/operations/testing`; `src/outcomes/run-outcome-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a ledger opened without the projection, a table filled at open, an interrupted fill done again, and a table found and not filled again. That ledgers that start together fill the table once is tested on PostgreSQL alone, in `src/outcomes/run-outcomes-on-postgresql.test.ts`. That a server waits past the migrator's 10 s for a migration lock another server holds is tested with a fake lock and a fake clock in `src/postgresql/postgresql-run-outcomes.test.ts`, and on PostgreSQL in `src/postgresql/connections-on-postgresql.test.ts`, where a client holds the lock for 12 s. The part on the keyed projections, `src/projections/projections-behaviour.ts`, the rows kept, the reads by columns, in order and from where a page ended, the counts, the rows due across playbooks and the next due time, the rows a mapping keys over two stream kinds and the reader's advance, which a fold leaves as it stands unless it sets the columns, runs on both stores and on the in-memory ledger, in `src/projections/projections-in-memory.test.ts`, with the `runTallyRows` and `topicRows` projections of `@on.auto/operations/testing`, and `src/projections/projection-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a table made with its indexes and filled at open with the version before it dropped, a table found and not filled again, and a table keyed by its mapping filled in the order its facts were appended, over more than one batch. `src/postgresql/postgresql-projections.test.ts` checks the statements of the reads, the advance, the ordered fill and the fold's upsert on PostgreSQL without a database, and `src/postgresql/advances-on-postgresql.test.ts`, on PostgreSQL alone, a fold that read a row before an advance committed. The server tests the mapping of `@on.auto/definitions` on both stores, through the ledger it composes. Its part on reading what a playbook recorded, `src/testing/recorded-behaviour.ts`, `src/testing/runs-behaviour.ts`, `src/run-filters/run-filters-behaviour.ts`, `src/lineage/lineage-behaviour.ts` and `src/heads/heads-behaviour.ts`, the ids, causes and correlations a read gives, the runs of one capability or name, the read by correlation, the read from inside a record, and the versions and the read of heads, also runs on the in-memory ledger of `@on.auto/operations`, in `src/recorded/recorded-in-memory.test.ts`, so the three agree. A read behind an append whose transaction is still open, the same read by correlation, the same read newest first, a list of runs oldest first behind such an append, a write open in another database that hides nothing, and a start that finds the indexes while an append is open are tested on PostgreSQL alone, since SQLite serialises appends. Before each read of what a playbook recorded, the PostgreSQL entry of the suite waits until every committed message of its own database is older than every write open on the server, polling every 20 ms for at most 10 s and failing the test past that; the tests that wait have a timeout of 30 s, above vitest's 5 s. Waiting for the ledger's own horizon is not enough when other tests write on the same server: a write in another database that began before a message and ends while a read runs is in doubt to that read, which then stays behind it and may answer nothing, as a probe that ended such a write during a read confirmed. The test of a write open in another database waits for every write but that one. The SQLite entry and the in-memory ledger do not wait. `src/testing` holds 15 files, the most a folder holds, so the suite of lineage opened `src/lineage`, the suite of heads `src/heads`, the suite of the runs of one definition `src/run-filters`, the suite of the context of each message, the place of each record and the plans of the reads by definition and by id `src/context`, and the suite of recorded content `src/content`, which runs the behaviour suite of the port, `recordedContentBehaviour` of `@on.auto/operations/testing`, on both stores, reading through a second ledger on the same database and counting the chunks no head names, and adds what only chunks in a database show: content of no bytes, 100 bytes and 5 MB kept in chunks of 1 MiB under a head written last and read back a chunk at a time, a read whose chunk is missing failing after the chunks before it, no last chunk handed over of bytes that are not their digest's, a read of a staged content failing when its run is unstaged during it, and the chunks no head names swept on the ledger's schedule and before a close (`unnamed-chunks-behaviour.ts`, with `sweepsWatched` of `watched-sweeps.ts`, which lets a test wait for a sweep or hold it); its order of writes, the chunks first and the head last, the removal of a put's own chunks when another head stands, a read of a chunk its store answers as a `Uint8Array` or as an `ArrayBuffer`, and an unstage's record and then its one transaction, the heads first, `src/content/content-store.test.ts` checks without a database, and `src/content/postgresql-content.test.ts` the hex a chunk travels as and the transaction of an unstage on one PostgreSQL connection, rolled back when a delete fails. The suites of kept secrets, `rowsBehaviour` and `storeBehaviour` of `@on.auto/secrets/testing`, run beside the ledger's on both stores, in the same two files, through `src/secrets/kept-secrets-behaviour.ts`, each on a database whose ledger opened first: every claim on the rows, and every claim on the store over them with the ledger's keeper and a key in hand, each moment set with `TestClock`, as that package's README lists them under [Testing](../secrets/README.md#testing), and the rows opened on connections of their own on the database the ledger made, which on SQLite migrate it again, and which leave it in the layout the ledger wrote. `src/secrets/postgresql-secret-rows.test.ts` checks the rows on PostgreSQL without a database, through fake connections: their pool, a lost connection logged, and each statement whole with the values it binds, the insert, the read with the end of its open claim, the forget and the holding it answers, the replacement, the claim with its condition of no open claim, the read of a keeper's rows under a kid, and the re-holding with its condition on the version and the holding read; `src/postgresql/postgresql-schema.test.ts` checks that the schema step creates its table only when it is missing, and that the migration takes its lock and lists the tables of the schema, then writes the layout into a database that holds none, or reads it from one that holds `ledger_layout`, before anything else. The read of one event by its id, with its data, context and place, no page cursor, and nothing of another playbook, is part of the suite of lineage, so it runs on both stores and on the in-memory ledger. `src/postgresql-reads/postgresql-runs.test.ts` checks the statement of that read on PostgreSQL without a database. `src/signal/append-signal.test.ts` tests the signal of an append on SQLite. +The ledger's behaviour is one suite, in `src/testing/ledger-behaviour.ts`, that `src/ledger.test.ts` runs on SQLite and `src/postgresql/ledger-on-postgresql.test.ts` runs on PostgreSQL. `@on.auto/ledger/testing` exports it, `ledgerBehaviour(entry)`, with `LedgerEntry`, what a store hands the suite, so a ledger opened on a driver of its own runs the same suite. The parts of the SQLite entry that hang on SQLite's dialect rather than on its driver are exported from the main entry, which loads no driver: `sqliteLayout`, the `LayoutDialect` that lists the tables of the schema, `sqlitePlanOf(execute, read)`, the plans SQLite makes of the statements a read of a `RecordedStore` asks, and `sqliteDefinitionStreamsIndexed(execute)`, whether the read of the definition streams goes through its index, each over a `SQLExecutor` of dumbo that the caller opens on its driver (`src/recorded/sqlite-plans.ts`), beside the types the entry names, `LedgerStore`, `RecordedStore` and `StatementExecutor`. This package's own entry opens them on `sqlite3` (`src/recorded/sqlite-checks.ts`), and `src/ledger.test.ts` lists the indexes and the tables it expects SQLite to name. Its part on the layout of the ledger, `src/layout/ledger-layout-behaviour.ts`, runs on both stores: a database that holds no table given the layout and opened again in it, a database in another layout refused with the layout found and the layout kept, in the words of the refusal, and left as it was, and a database that holds a table but no layout number refused the same way and left as it was; `src/layout/ledger-layout.test.ts` pins the layout on SQLite (see [The layout of the ledger](#the-layout-of-the-ledger)), `src/layout/sqlite-schema.test.ts` checks without a database that SQLite lists the tables and writes the layout in one transaction, and `src/layout/sqlite-file-in-layout.test.ts` that `aSQLiteFileInLayout` of `@on.auto/ledger/testing`, with which the server's tests make a ledger of another version, puts a new file in the layout it is given. Its part on the outcomes of runs, `src/outcomes/run-outcomes-behaviour.ts`, also runs on the in-memory ledger, in `src/outcomes/run-outcomes-in-memory.test.ts`, with the `runTallies` mapping of `@on.auto/operations/testing`; `src/outcomes/run-outcome-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a ledger opened without the projection, a table filled at open, an interrupted fill done again, and a table found and not filled again. That ledgers that start together fill the table once is tested on PostgreSQL alone, in `src/outcomes/run-outcomes-on-postgresql.test.ts`. That a server waits past the migrator's 10 s for a migration lock another server holds is tested with a fake lock and a fake clock in `src/postgresql/postgresql-run-outcomes.test.ts`, and on PostgreSQL in `src/postgresql/connections-on-postgresql.test.ts`, where a client holds the lock for 12 s. The part on the keyed projections, `src/projections/projections-behaviour.ts`, the rows kept, the reads by columns, in order and from where a page ended, the counts, the rows due across playbooks and the next due time, the rows a mapping keys over two stream kinds and the reader's advance, which a fold leaves as it stands unless it sets the columns, runs on both stores and on the in-memory ledger, in `src/projections/projections-in-memory.test.ts`, with the `runTallyRows` and `topicRows` projections of `@on.auto/operations/testing`, and `src/projections/projection-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a table made with its indexes and filled at open with the version before it dropped, a table found and not filled again, and a table keyed by its mapping filled in the order its facts were appended, over more than one batch. `src/postgresql/postgresql-projections.test.ts` checks the statements of the reads, the advance, the ordered fill and the fold's upsert on PostgreSQL without a database, and `src/postgresql/advances-on-postgresql.test.ts`, on PostgreSQL alone, a fold that read a row before an advance committed. The server tests the mapping of `@on.auto/definitions` on both stores, through the ledger it composes. Its part on reading what a playbook recorded, `src/testing/recorded-behaviour.ts`, `src/testing/runs-behaviour.ts`, `src/run-filters/run-filters-behaviour.ts`, `src/lineage/lineage-behaviour.ts` and `src/heads/heads-behaviour.ts`, the ids, causes and correlations a read gives, the runs of one capability or name, the read by correlation, the read from inside a record, and the versions and the read of heads, also runs on the in-memory ledger of `@on.auto/operations`, in `src/recorded/recorded-in-memory.test.ts`, so the three agree. A read behind an append whose transaction is still open, the same read by correlation, the same read newest first, a list of runs oldest first behind such an append, a write open in another database that hides nothing, and a start that finds the indexes while an append is open are tested on PostgreSQL alone, since SQLite serialises appends. Before each read of what a playbook recorded, the PostgreSQL entry of the suite waits until every committed message of its own database is older than every write open on the server, polling every 20 ms for at most 10 s and failing the test past that; the tests that wait have a timeout of 30 s, above vitest's 5 s. Waiting for the ledger's own horizon is not enough when other tests write on the same server: a write in another database that began before a message and ends while a read runs is in doubt to that read, which then stays behind it and may answer nothing, as a probe that ended such a write during a read confirmed. The test of a write open in another database waits for every write but that one. The SQLite entry and the in-memory ledger do not wait. `src/testing` holds 15 files, the most a folder holds, so the suite of lineage opened `src/lineage`, the suite of heads `src/heads`, the suite of the runs of one definition `src/run-filters`, the suite of the context of each message, the place of each record and the plans of the reads by definition and by id `src/context`, and the suite of recorded content `src/content`, which runs the behaviour suite of the port, `recordedContentBehaviour` of `@on.auto/operations/testing`, on both stores, reading through a second ledger on the same database and counting the chunks no head names, and adds what only chunks in a database show: content of no bytes, 100 bytes and 5 MB kept in chunks of 1 MiB under a head written last and read back a chunk at a time, a read whose chunk is missing failing after the chunks before it, no last chunk handed over of bytes that are not their digest's, a read of a staged content failing when its run is unstaged during it, and the chunks no head names swept when the ledger's chore is called, and a sweep under way finished before a close (`unnamed-chunks-behaviour.ts`, with `sweepsWatched` of `watched-sweeps.ts`, which lets a test wait for a sweep or hold it); its order of writes, the chunks first and the head last, the removal of a put's own chunks when another head stands, a read of a chunk its store answers as a `Uint8Array` or as an `ArrayBuffer`, and an unstage's record and then its one transaction, the heads first, `src/content/content-store.test.ts` checks without a database, and `src/content/postgresql-content.test.ts` the hex a chunk travels as and the transaction of an unstage on one PostgreSQL connection, rolled back when a delete fails. The suites of kept secrets, `rowsBehaviour` and `storeBehaviour` of `@on.auto/secrets/testing`, run beside the ledger's on both stores, in the same two files, through `src/secrets/kept-secrets-behaviour.ts`, each on a database whose ledger opened first: every claim on the rows, and every claim on the store over them with the ledger's keeper and a key in hand, each moment set with `TestClock`, as that package's README lists them under [Testing](../secrets/README.md#testing), and the rows opened on connections of their own on the database the ledger made, which on SQLite migrate it again, and which leave it in the layout the ledger wrote. `src/secrets/postgresql-secret-rows.test.ts` checks the rows on PostgreSQL without a database, through fake connections: their pool, a lost connection logged, and each statement whole with the values it binds, the insert, the read with the end of its open claim, the forget and the holding it answers, the replacement, the claim with its condition of no open claim, the read of a keeper's rows under a kid, and the re-holding with its condition on the version and the holding read; `src/postgresql/postgresql-schema.test.ts` checks that the schema step creates its table only when it is missing, and that the migration takes its lock and lists the tables of the schema, then writes the layout into a database that holds none, or reads it from one that holds `ledger_layout`, before anything else. The read of one event by its id, with its data, context and place, no page cursor, and nothing of another playbook, is part of the suite of lineage, so it runs on both stores and on the in-memory ledger. `src/postgresql-reads/postgresql-runs.test.ts` checks the statement of that read on PostgreSQL without a database. `src/signal/append-signal.test.ts` tests the signal of an append on SQLite. ```bash docker run --detach --name ledger-pg-test --publish 127.0.0.1:19632:5432 --env POSTGRES_PASSWORD=ledger-test postgres:18.6-alpine diff --git a/packages/ledger/src/connection/ledger-connection.ts b/packages/ledger/src/connection/ledger-connection.ts new file mode 100644 index 000000000..eb5a3c7cc --- /dev/null +++ b/packages/ledger/src/connection/ledger-connection.ts @@ -0,0 +1,25 @@ +import { Context } from 'effect'; + +import type { LedgerStreams } from '../event-store.ts'; +import type { Statement } from './statement.ts'; + +type Rows = readonly unknown[]; + +export type ReadRows = (statement: Statement) => Promise; + +export type Dialect = 'sqlite' | 'postgresql'; + +export interface ConnectionPorts { + readonly dialect: Dialect; + readonly streams: LedgerStreams; + readonly read: ReadRows; + readonly write: (statement: Statement) => Promise; + readonly transaction: (statements: readonly Statement[]) => Promise; + readonly readConsistently: (reads: (read: ReadRows) => Promise) => Promise; + readonly sharedClock: (() => Promise) | null; + readonly migrated: (statements: readonly Statement[]) => Promise; +} + +export class LedgerConnection extends Context.Service()( + '@on.auto/ledger/LedgerConnection', +) {} diff --git a/packages/ledger/src/connection/sqlite-connection.test.ts b/packages/ledger/src/connection/sqlite-connection.test.ts new file mode 100644 index 000000000..d2a27eee6 --- /dev/null +++ b/packages/ledger/src/connection/sqlite-connection.test.ts @@ -0,0 +1,131 @@ +import { setTimeout } from 'node:timers/promises'; + +import { Effect } from 'effect'; +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { stamped } from '../testing/happenings.ts'; +import { openConnection, type OpenConnection } from '../testing/open-ledger.ts'; +import { tally } from '../testing/tally.ts'; +import { temporaryDatabase } from '../testing/temporary-database.ts'; +import type { ReadRows } from './ledger-connection.ts'; +import { statement } from './statement.ts'; + +function aDatabaseFile(): string { + const { fileName, remove } = temporaryDatabase(); + onTestFinished(remove); + return fileName; +} + +async function connectedTo(fileName: string): Promise { + const opened = await openConnection(fileName); + onTestFinished(opened.dispose); + return opened; +} + +const besideTable = statement`CREATE TABLE IF NOT EXISTS beside (name TEXT NOT NULL PRIMARY KEY, amount BIGINT NOT NULL)`; + +const namesRead = statement`SELECT name FROM beside ORDER BY name`; + +function added(name: string, amount = 1) { + return statement`INSERT INTO beside (name, amount) VALUES (${name}, ${amount})`; +} + +const figuresRead = statement`SELECT amount, (SELECT COUNT(*) FROM beside) AS counted FROM beside WHERE name = ${'north'}`; + +const raised = statement`UPDATE beside SET amount = ${2} WHERE name = ${'north'}`; + +async function withOneRow(fileName: string): Promise { + const opened = await connectedTo(fileName); + await opened.connection.migrated([besideTable, added('north')]); + return opened; +} + +describe('the connection of the ledger on SQLite', () => { + it('runs its statements in the database of the ledger, beside its tables, and appends its streams there', async () => { + const fileName = aDatabaseFile(); + const first = await withOneRow(fileName); + await first.connection.streams.append('org/acme/tallies', [{ type: 'counted', data: { by: 2 } }], { + expectedVersion: 0, + context: stamped, + }); + const second = await connectedTo(fileName); + + expect(await second.connection.read(namesRead)).toEqual([{ name: 'north' }]); + expect(await second.connection.read(statement`SELECT COUNT(*) AS streams FROM emt_streams`)).toEqual([ + { streams: 1 }, + ]); + expect(await Effect.runPromise(second.ledger.load('org/acme/tallies', tally))).toEqual({ state: 2, version: 1 }); + expect([first.connection.dialect, first.connection.sharedClock]).toEqual(['sqlite', null]); + }); + + it('writes in one transaction, keeping it only when its last statement writes a row, and nothing of one that fails', async () => { + const { connection } = await withOneRow(aDatabaseFile()); + + const kept = await connection.transaction([ + added('east'), + statement`UPDATE beside SET amount = ${3} WHERE name = ${'east'} RETURNING name`, + ]); + const unkept = await connection.transaction([ + added('south'), + statement`UPDATE beside SET amount = ${3} WHERE name = ${'nobody'}`, + ]); + const failing = connection.transaction([added('west'), statement`INSERT INTO nowhere VALUES (${1})`]); + + await expect(failing).rejects.toThrow('no such table: nowhere'); + expect([kept, unkept, await connection.read(namesRead)]).toEqual([ + [{ name: 'east' }], + [], + [{ name: 'east' }, { name: 'north' }], + ]); + }); +}); + +describe('the reads of the connection of the ledger in one snapshot, on SQLite', () => { + it('read what was committed when their first read began, and none of what is written while they read', async () => { + const { connection } = await withOneRow(aDatabaseFile()); + + const read = await connection.readConsistently(async (reading) => { + const before = await reading(figuresRead); + await connection.write(raised); + await connection.write(added('south', 5)); + return [...before, ...(await reading(figuresRead))]; + }); + + expect([...read, ...(await connection.read(figuresRead))]).toEqual([ + { amount: 1, counted: 1 }, + { amount: 1, counted: 1 }, + { amount: 2, counted: 2 }, + ]); + }); + + it('fail as their reads fail', async () => { + const { connection } = await withOneRow(aDatabaseFile()); + + await expect( + connection.readConsistently((reading) => reading(statement`SELECT amount FROM nowhere`)), + ).rejects.toThrow('no such table: nowhere'); + }); + + it('hold the one connection of a private database in memory, so a write asked meanwhile lands once they end', async () => { + const { connection } = await withOneRow(':memory:'); + const firstRead = Promise.withResolvers(); + const readTwice = async (reading: ReadRows) => { + const before = await reading(figuresRead); + firstRead.resolve(); + await setTimeout(50); + return [...before, ...(await reading(figuresRead))]; + }; + + const reading = connection.readConsistently(readTwice); + await firstRead.promise; + const writing = connection.write(raised); + const read = await reading; + await writing; + + expect([...read, ...(await connection.read(figuresRead))]).toEqual([ + { amount: 1, counted: 1 }, + { amount: 1, counted: 1 }, + { amount: 2, counted: 1 }, + ]); + }); +}); diff --git a/packages/ledger/src/connection/sqlite-connection.ts b/packages/ledger/src/connection/sqlite-connection.ts new file mode 100644 index 000000000..1881f8939 --- /dev/null +++ b/packages/ledger/src/connection/sqlite-connection.ts @@ -0,0 +1,81 @@ +import type { SQL } from '@event-driven-io/dumbo'; + +import type { LedgerStreams } from '../event-store.ts'; +import { inTurn } from '../projections/inline-projection.ts'; +import type { ConnectionPorts, ReadRows } from './ledger-connection.ts'; +import { lastAnswerOf, wroteARow, type StatementAnswer } from './statement-answers.ts'; +import { onSQLite } from './statement.ts'; + +interface WriterTransaction { + readonly execute: { readonly command: (sql: SQL) => Promise }; +} + +interface ReaderTransaction { + readonly execute: { readonly query: (sql: SQL) => Promise }; +} + +type Handled = { readonly success: boolean; readonly result: A }; + +interface SnapshotReader { + readonly withTransaction: ( + handle: (transaction: ReaderTransaction) => Promise>, + options: typeof deferred, + ) => Promise; +} + +export interface SQLitePool { + readonly execute: { + readonly query: (sql: SQL) => Promise; + readonly command: (sql: SQL) => Promise; + }; + readonly withTransaction: ( + handle: (transaction: WriterTransaction & ReaderTransaction) => Promise>, + options?: typeof deferred, + ) => Promise; + readonly withConnection: ( + handle: (reader: SnapshotReader) => Promise, + options: { readonly readonly: true }, + ) => Promise; +} + +export interface SQLiteConnecting { + readonly inMemory: boolean; + readonly streams: LedgerStreams; +} + +const deferred = { mode: 'DEFERRED' } as const; + +function readsIn({ execute }: ReaderTransaction): ReadRows { + return async (asked) => (await execute.query(onSQLite(asked))).rows; +} + +function snapshotsOn(pool: SQLitePool, inMemory: boolean): ConnectionPorts['readConsistently'] { + const read = (reads: (read: ReadRows) => Promise) => { + const handle = async (transaction: ReaderTransaction): Promise> => ({ + success: true, + result: await reads(readsIn(transaction)), + }); + return inMemory + ? pool.withTransaction(handle, deferred) + : pool.withConnection((reader) => reader.withTransaction(handle, deferred), { readonly: true }); + }; + return read; +} + +export function sqliteConnectionOn(pool: SQLitePool, { inMemory, streams }: SQLiteConnecting): ConnectionPorts { + const write = async (asked: Parameters[0]) => (await pool.execute.command(onSQLite(asked))).rows; + return { + dialect: 'sqlite', + streams, + read: async (asked) => (await pool.execute.query(onSQLite(asked))).rows, + write, + transaction: (statements) => + pool.withTransaction(async ({ execute }) => { + const last = await lastAnswerOf(statements, (asked) => execute.command(onSQLite(asked))); + return { success: wroteARow(last), result: last.rows }; + }), + readConsistently: snapshotsOn(pool, inMemory), + sharedClock: null, + migrated: (statements) => inTurn(statements, write), + }; +} diff --git a/packages/ledger/src/connection/sqlite-opening.ts b/packages/ledger/src/connection/sqlite-opening.ts new file mode 100644 index 000000000..954d52926 --- /dev/null +++ b/packages/ledger/src/connection/sqlite-opening.ts @@ -0,0 +1,33 @@ +import type { AppendObserver } from '../event-store.ts'; +import { ledgerLayerOver, type OpenedLedger, type StoreLayerOptions } from '../ledger-layer.ts'; +import type { KeptTables } from '../projections/projection-parts.ts'; +import { pooledOptionsOf, sqliteEventStore, type AnyDriver, type SQLiteStoreOptions } from '../sqlite-event-store.ts'; +import { sqliteConnectionOn } from './sqlite-connection.ts'; + +export interface SQLiteOpening { + readonly optionsOf: () => SQLiteStoreOptions; + readonly inMemory: boolean; +} + +export function sqliteLedgerOpened( + { optionsOf, inMemory }: SQLiteOpening, + kept: KeptTables = {}, + observer?: AppendObserver, +): OpenedLedger { + const options = pooledOptionsOf(optionsOf); + const onThePool = () => options; + return { + store: sqliteEventStore(onThePool, kept, observer), + connection: sqliteConnectionOn(options.pool, { inMemory, streams: sqliteEventStore(onThePool, {}, observer) }), + }; +} + +export function sqliteLedgerLayer( + opening: SQLiteOpening, + { runOutcomes, projections, appends, content, observer }: StoreLayerOptions = {}, +) { + return ledgerLayerOver(() => sqliteLedgerOpened(opening, { runOutcomes, projections }, observer), { + appends, + content, + }); +} diff --git a/packages/ledger/src/connection/statement-answers.ts b/packages/ledger/src/connection/statement-answers.ts new file mode 100644 index 000000000..42b8edc9c --- /dev/null +++ b/packages/ledger/src/connection/statement-answers.ts @@ -0,0 +1,22 @@ +import type { Statement } from './statement.ts'; + +export interface StatementAnswer { + readonly rows: readonly unknown[]; + readonly rowCount: number | null; +} + +const nothingAnswered: StatementAnswer = { rows: [], rowCount: 0 }; + +export function lastAnswerOf( + statements: readonly Statement[], + answered: (statement: Statement) => Promise, +): Promise { + return statements.reduce(async (before: Promise, next: Statement) => { + await before; + return answered(next); + }, Promise.resolve(nothingAnswered)); +} + +export function wroteARow({ rowCount }: StatementAnswer): boolean { + return Number(rowCount) > 0; +} diff --git a/packages/workflow-host/src/database/statement.test.ts b/packages/ledger/src/connection/statement.test.ts similarity index 63% rename from packages/workflow-host/src/database/statement.test.ts rename to packages/ledger/src/connection/statement.test.ts index b04e3cc4c..2abe93f75 100644 --- a/packages/workflow-host/src/database/statement.test.ts +++ b/packages/ledger/src/connection/statement.test.ts @@ -1,12 +1,11 @@ -import { Effect } from 'effect'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, onTestFinished } from 'vitest'; -import { openedOn } from '../testing/host-files.ts'; +import { openConnection } from '../testing/open-ledger.ts'; import { statement, textOnPostgreSQL } from './statement.ts'; const runKey = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -describe('a statement of the host', () => { +describe('a statement beside the ledger', () => { it('numbers its values on PostgreSQL, in the order they appear', () => { const due = statement`UPDATE workflow_timers SET due_at = ${2000} WHERE run_key = ${runKey} AND timer_id = ${'1'}`; @@ -17,9 +16,10 @@ describe('a statement of the host', () => { }); it('binds the same values on SQLite', async () => { - const database = await openedOn({ store: 'sqlite', file: ':memory:' }); + const { connection, dispose } = await openConnection(':memory:'); + onTestFinished(dispose); - const rows = await Effect.runPromise(database.read(statement`SELECT ${7} AS seven, ${null} AS empty`)); + const rows = await connection.read(statement`SELECT ${7} AS seven, ${null} AS empty`); expect(rows).toEqual([{ seven: 7, empty: null }]); }); diff --git a/packages/workflow-host/src/database/statement.ts b/packages/ledger/src/connection/statement.ts similarity index 100% rename from packages/workflow-host/src/database/statement.ts rename to packages/ledger/src/connection/statement.ts diff --git a/packages/ledger/src/content/content-sweep.test.ts b/packages/ledger/src/content/content-sweep.test.ts index 4031ea383..4ae4084f0 100644 --- a/packages/ledger/src/content/content-sweep.test.ts +++ b/packages/ledger/src/content/content-sweep.test.ts @@ -1,14 +1,12 @@ import { sqlite3EventStoreDriver } from '@event-driven-io/emmett-sqlite/sqlite3'; import { textToKeep } from '@on.auto/operations'; -import { Effect, Layer, Logger } from 'effect'; -import { TestClock } from 'effect/testing'; +import { Effect, Logger } from 'effect'; import { describe, expect, it, onTestFinished } from 'vitest'; +import { sqliteLedgerOpened } from '../connection/sqlite-opening.ts'; import { ledgerLayerOver } from '../ledger-layer.ts'; -import { sqliteEventStore } from '../sqlite-event-store.ts'; -import { openLedgerWith } from '../testing/open-ledger.ts'; -import { until } from '../testing/until.ts'; -import { sweptHourly, unnamedChunksLastMs, type ContentSweeping } from './content-sweep.ts'; +import { openChoresWith } from '../testing/open-ledger.ts'; +import { orphansSweptOf, unnamedChunksLastMs, type ContentSweeping } from './content-sweep.ts'; interface Sweeps extends ContentSweeping { readonly asked: readonly number[]; @@ -39,43 +37,40 @@ const couldNotSweep = 'The ledger could not sweep what puts that never finished left of their content; it sweeps again in an hour'; describe('the sweep of the chunks no head names', () => { - it('runs at once and then every hour, sweeping what an hour has left, and logs a first sweep that fails and goes on', async () => { + it('sweeps what an hour has left at the time it is called, answers that it is due an hour on, and logs a sweep that fails and goes on', async () => { const sweeps = sweepsFailingAt(1); const { logged, layer } = capturing(); + const sweptAt = (now: number) => Effect.runPromise(orphansSweptOf(sweeps)(now).pipe(Effect.provide(layer))); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(10 * anHour); - yield* sweptHourly(sweeps); - yield* TestClock.adjust('1 hour'); - yield* TestClock.adjust('1 hour'); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), layer))), - ); + const first = await sweptAt(10 * anHour); + const second = await sweptAt(first); + const third = await sweptAt(second); + expect([first, second, third]).toEqual([11 * anHour, 12 * anHour, 13 * anHour]); expect(sweeps.asked).toEqual([9 * anHour, 10 * anHour, 11 * anHour]); expect(logged).toEqual([expect.stringContaining(couldNotSweep)]); expect(logged[0]).toContain('The database went away'); }); - it('never holds the ledger back from opening, though its first sweep fails', async () => { - const sweeps = sweepsFailingAt(1); - const { logged, layer } = capturing(); - const store = () => ({ - ...sqliteEventStore(() => ({ driver: sqlite3EventStoreDriver, fileName: ':memory:' })), - sweepContent: sweeps.sweepContent, - }); - const { ledger, dispose } = await openLedgerWith(ledgerLayerOver(store).pipe(Layer.provide(layer))); + it('runs on no clock of the ledger, which sweeps nothing as it opens or keeps content, and sweeps once its chore is called', async () => { + const sweeps = sweepsFailingAt(0); + const opened = () => { + const { store, connection } = sqliteLedgerOpened({ + optionsOf: () => ({ driver: sqlite3EventStoreDriver, fileName: ':memory:' }), + inMemory: true, + }); + return { store: { ...store, sweepContent: sweeps.sweepContent }, connection }; + }; + const { ledger, sweptAt, dispose } = await openChoresWith(ledgerLayerOver(opened)); onTestFinished(dispose); const kept = await Effect.runPromise( ledger.content.put({ org: 'acme', playbook: 'alpha' }, textToKeep('{}', 'application/json')), ); + const before = [...sweeps.asked]; + await sweptAt(5 * anHour); expect(kept).toMatchObject({ bytes: 2, mediaType: 'application/json' }); - await until(() => { - expect(logged).toEqual([expect.stringContaining(couldNotSweep)]); - }); + expect([before, sweeps.asked]).toEqual([[], [4 * anHour]]); }); }); diff --git a/packages/ledger/src/content/content-sweep.ts b/packages/ledger/src/content/content-sweep.ts index 5efbeb950..20102d7f3 100644 --- a/packages/ledger/src/content/content-sweep.ts +++ b/packages/ledger/src/content/content-sweep.ts @@ -1,6 +1,6 @@ import { SQL } from '@event-driven-io/dumbo'; import { uuidV7TimeOf } from '@on.auto/operations'; -import { Clock, Effect, type Scope } from 'effect'; +import { Context, Effect } from 'effect'; import type { StatementExecutor } from '../event-store.ts'; import { inTurn } from '../projections/inline-projection.ts'; @@ -12,6 +12,12 @@ export interface ContentSweeping { readonly sweepContent: (before: number) => Promise; } +export interface ChorePorts { + readonly sweepOrphans: (now: number) => Effect.Effect; +} + +export class LedgerChores extends Context.Service()('@on.auto/ledger/LedgerChores') {} + function sweptShelf( execute: StatementExecutor, { chunks, heads, placeColumns }: Shelf, @@ -34,18 +40,17 @@ export function contentSweepOn(execute: StatementExecutor): ContentSweeping['swe return (before) => inTurn(shelves, (shelf) => sweptShelf(execute, shelf, uuidV7TimeOf(before))); } -function sweptNow({ sweepContent }: ContentSweeping): Effect.Effect { - return Effect.uninterruptible( - Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.promise(() => sweepContent(now - unnamedChunksLastMs))), - ); -} - -export function sweptHourly(store: ContentSweeping): Effect.Effect { - const sweep = Effect.catchCause(sweptNow(store), (cause) => - Effect.logError( - 'The ledger could not sweep what puts that never finished left of their content; it sweeps again in an hour', - cause, - ), - ); - return Effect.asVoid(Effect.forkScoped(Effect.forever(Effect.andThen(sweep, Effect.sleep('1 hour'))))); +export function orphansSweptOf({ sweepContent }: ContentSweeping): ChorePorts['sweepOrphans'] { + return (now) => + Effect.uninterruptible( + Effect.promise(() => sweepContent(now - unnamedChunksLastMs)).pipe( + Effect.catchCause((cause) => + Effect.logError( + 'The ledger could not sweep what puts that never finished left of their content; it sweeps again in an hour', + cause, + ), + ), + Effect.as(now + unnamedChunksLastMs), + ), + ); } diff --git a/packages/ledger/src/content/unnamed-chunks-behaviour.ts b/packages/ledger/src/content/unnamed-chunks-behaviour.ts index 72351aa72..8e80a335f 100644 --- a/packages/ledger/src/content/unnamed-chunks-behaviour.ts +++ b/packages/ledger/src/content/unnamed-chunks-behaviour.ts @@ -4,9 +4,9 @@ import { Schema } from 'effect'; import { describe, expect, it, onTestFinished } from 'vitest'; import type { LedgerEntry } from '../testing/ledger-entry.ts'; -import { openLedgerWith } from '../testing/open-ledger.ts'; +import { openChoresWith, openLedgerWith } from '../testing/open-ledger.ts'; import { unnamedChunksLastMs } from './content-sweep.ts'; -import { sweepsWatched } from './watched-sweeps.ts'; +import { endingOf, sweepsWatched } from './watched-sweeps.ts'; const UploadRows = Schema.Array(Schema.Struct({ upload_id: Schema.String })); @@ -41,10 +41,8 @@ async function orphanedLike(entry: LedgerEntry, database: string, named: Named, } async function keptAndOrphaned(entry: LedgerEntry, database: string): Promise { - const watched = sweepsWatched(() => entry.storeOn(database)); - const { ledger, dispose } = await openLedgerWith(watched.layer); + const { ledger, dispose } = await openLedgerWith(entry.ledgerOn(database)); onTestFinished(dispose); - await watched.finished; await resultOf(ledger.content.put(alpha, textToKeep('{"kept":true}', 'application/json'))); await resultOf(ledger.content.stage(alpha, 'run-1', (await generated(1000)).content())); const named = { @@ -64,16 +62,18 @@ async function uploadsLeft(entry: LedgerEntry, database: string): Promise { +function sweptByItsChore(entry: LedgerEntry): void { + it('sweeps the chunks no head names when its chore is called, once their upload is an hour old, keeps every chunk a head names, and answers that it is due again an hour on', async () => { const database = await entry.aDatabase(); const { fresh, named } = await keptAndOrphaned(entry, database); - const watched = sweepsWatched(() => entry.storeOn(database)); - - const { dispose } = await openLedgerWith(watched.layer); + const watched = sweepsWatched(() => entry.openedOn(database)); + const { sweptAt, dispose } = await openChoresWith(watched.layer); onTestFinished(dispose); + const now = Date.now(); + + const dueAgainAt = await sweptAt(now); - expect(await watched.finished).toBe('swept'); + expect([await watched.finished, dueAgainAt]).toEqual(['swept', now + unnamedChunksLastMs]); expect(await uploadsLeft(entry, database)).toEqual([...named, fresh, fresh].toSorted()); }); } @@ -83,15 +83,16 @@ function sweepFinishedBeforeClosing(entry: LedgerEntry): void { const database = await entry.aDatabase(); const { fresh, named } = await keptAndOrphaned(entry, database); const held = Promise.withResolvers(); - const watched = sweepsWatched(() => entry.storeOn(database), held.promise); - const { dispose } = await openLedgerWith(watched.layer); + const watched = sweepsWatched(() => entry.openedOn(database), held.promise); + const { sweptAt, dispose } = await openChoresWith(watched.layer); + const sweeping = endingOf(sweptAt(Date.now())); await watched.started; const disposing = dispose(); held.resolve(); await disposing; - expect(await watched.finished).toBe('swept'); + expect(await Promise.all([watched.finished, sweeping])).toEqual(['swept', 'ended']); expect(await uploadsLeft(entry, database)).toEqual([...named, fresh, fresh].toSorted()); }); } @@ -111,7 +112,7 @@ function sweptBefore(entry: LedgerEntry): void { export function unnamedChunksSwept(entry: LedgerEntry): void { describe('the chunks no head names', () => { - sweptOnItsSchedule(entry); + sweptByItsChore(entry); sweepFinishedBeforeClosing(entry); sweptBefore(entry); }); diff --git a/packages/ledger/src/content/watched-sweeps.ts b/packages/ledger/src/content/watched-sweeps.ts index cf0ed8a20..a10a7db0f 100644 --- a/packages/ledger/src/content/watched-sweeps.ts +++ b/packages/ledger/src/content/watched-sweeps.ts @@ -1,28 +1,37 @@ -import type { Ledger } from '@on.auto/operations'; import type { Layer } from 'effect'; -import type { LedgerStore } from '../event-store.ts'; -import { ledgerLayerOver } from '../ledger-layer.ts'; +import { ledgerLayerOver, type LedgerServices, type OpenedLedger } from '../ledger-layer.ts'; export interface WatchedSweeps { - readonly layer: Layer.Layer; + readonly layer: Layer.Layer; readonly started: Promise; readonly finished: Promise; } -export function sweepsWatched(storeOf: () => LedgerStore, held: Promise = Promise.resolve()): WatchedSweeps { +export function sweepsWatched(openedOf: () => OpenedLedger, held: Promise = Promise.resolve()): WatchedSweeps { const started = Promise.withResolvers(); const finished = Promise.withResolvers(); - const open = (): LedgerStore => { - const store = storeOf(); + const open = (): OpenedLedger => { + const { store, connection } = openedOf(); return { - ...store, - sweepContent: async (before) => { - started.resolve(); - await held; - finished.resolve(await store.sweepContent(before).then(() => 'swept', String)); + store: { + ...store, + sweepContent: async (before) => { + started.resolve(); + await held; + finished.resolve(await store.sweepContent(before).then(() => 'swept', String)); + }, }, + connection, }; }; return { layer: ledgerLayerOver(open), started: started.promise, finished: finished.promise }; } + +function ended(): 'ended' { + return 'ended'; +} + +export function endingOf(chore: Promise): Promise<'ended'> { + return chore.then(ended, ended); +} diff --git a/packages/ledger/src/event-appender.ts b/packages/ledger/src/event-appender.ts index b2785c382..ab1840f8b 100644 --- a/packages/ledger/src/event-appender.ts +++ b/packages/ledger/src/event-appender.ts @@ -3,7 +3,7 @@ import { checkedContext, recordingOf, type Decided, type Recorded } from '@on.au import { Effect, type Schema } from 'effect'; import { eventCodecOf } from './event-codec.ts'; -import type { AppendPlace, EncodedEvent, StreamStore } from './event-store.ts'; +import type { AppendPlace, EncodedEvent, Streams } from './event-store.ts'; import { VersionConflict } from './version-conflict.ts'; export type EventAppender = ( @@ -13,7 +13,7 @@ export type EventAppender = ( ) => Effect.Effect; export function eventAppenderOf( - store: StreamStore, + store: Streams, eventSchema: Schema.ConstraintCodec, ): EventAppender { const { encode } = eventCodecOf(eventSchema); diff --git a/packages/ledger/src/event-store.ts b/packages/ledger/src/event-store.ts index 04be42266..0ba869212 100644 --- a/packages/ledger/src/event-store.ts +++ b/packages/ledger/src/event-store.ts @@ -84,10 +84,13 @@ export interface StoredPage { readonly lastExamined?: StoredPlace; } -export interface StreamStore { +export interface Streams { readonly mostEventsInOneAppend: number; readonly read: (stream: string, after?: number) => Promise; readonly append: (stream: string, events: readonly EncodedEvent[], place: AppendPlace) => Promise; +} + +export interface StreamStore extends Streams { readonly migrate: () => Promise; readonly close: () => Promise; } @@ -118,7 +121,9 @@ export interface DefinitionStreamsStore { readonly definitionStreams: (definitionType: string) => Promise; } -export interface EventStore extends StreamStore, RecordedStore, DefinitionStreamsStore {} +export interface LedgerStreams extends Streams, RecordedStore, DefinitionStreamsStore {} + +export interface EventStore extends StreamStore, LedgerStreams {} export interface RunOutcomesStore { readonly readRunOutcomes: ( diff --git a/packages/ledger/src/index.ts b/packages/ledger/src/index.ts index c18daea4b..b652d4c06 100644 --- a/packages/ledger/src/index.ts +++ b/packages/ledger/src/index.ts @@ -5,6 +5,9 @@ export { type AppendListener, type AppendSignal, } from './appends/append-signal.ts'; +export { LedgerConnection, type ConnectionPorts, type Dialect, type ReadRows } from './connection/ledger-connection.ts'; +export { statement, textOnPostgreSQL, type Statement, type StatementValue } from './connection/statement.ts'; +export { LedgerChores, type ChorePorts } from './content/content-sweep.ts'; export { eventAppenderOf, type EventAppender } from './event-appender.ts'; export { eventCodecOf, type EventCodec } from './event-codec.ts'; export type { @@ -15,6 +18,7 @@ export type { EncodedEvent, EventStore, LedgerStore, + LedgerStreams, RecordedPoint, RecordedStore, RecordedStream, @@ -25,6 +29,7 @@ export type { } from './event-store.ts'; export { sqliteLayout, type LayoutDialect } from './layout/layout-dialects.ts'; export { LedgerLayoutRefused } from './layout/ledger-layout-refused.ts'; +export type { LedgerServices, OpenedLedger } from './ledger-layer.ts'; export { cursorOf as recordedCursorOf } from './recorded/cursor.ts'; export { decisionLoop, @@ -34,7 +39,8 @@ export { type StreamAppend, type StreamLoad, } from './ledger-service.ts'; -export { sqliteEventStore, sqliteLedgerLayer, type SQLiteStoreOptions } from './sqlite-event-store.ts'; +export { sqliteLedgerLayer, sqliteLedgerOpened } from './connection/sqlite-opening.ts'; +export { sqliteEventStore, type SQLiteStoreOptions } from './sqlite-event-store.ts'; export { sqliteSecretRowsOn } from './secrets/sqlite-secret-rows.ts'; export { recordedReaderOf } from './recorded/recorded-reader.ts'; export { sqliteDefinitionStreamsIndexed, sqlitePlanOf } from './recorded/sqlite-plans.ts'; diff --git a/packages/ledger/src/ledger-layer.test.ts b/packages/ledger/src/ledger-layer.test.ts index 752c0ae7c..2e89ce0bf 100644 --- a/packages/ledger/src/ledger-layer.test.ts +++ b/packages/ledger/src/ledger-layer.test.ts @@ -7,7 +7,7 @@ import { Cause, Effect } from 'effect'; import { afterEach, describe, expect, it, onTestFinished } from 'vitest'; import { ledgerLayer } from './sqlite3.ts'; -import { openLedger, openLedgerWith } from './testing/open-ledger.ts'; +import { openChoresWith, openLedger } from './testing/open-ledger.ts'; import { tally } from './testing/tally.ts'; import { temporaryDatabase, type TemporaryDatabase } from './testing/temporary-database.ts'; @@ -120,10 +120,10 @@ describe('a ledger in a private in-memory database', () => { }); describe('a ledger given a content store of its own', () => { - it('keeps its content there, reads it back from there, and sweeps that store on its schedule', async () => { + it('keeps its content there, reads it back from there, and sweeps that store when its chore is called', async () => { const content = memoryRecordedContent(); const swept = Promise.withResolvers(); - const { ledger, dispose } = await openLedgerWith( + const { ledger, sweptAt, dispose } = await openChoresWith( ledgerLayer({ fileName: ':memory:', content: { @@ -143,6 +143,7 @@ describe('a ledger given a content store of its own', () => { expect(held).toMatchObject({ bytes: 2, mediaType: 'application/json' }); expect(await Effect.runPromise(ledger.content.get(alpha, kept.sha256))).toMatchObject({ bytes: 2 }); + await sweptAt(Date.now()); expect(await swept.promise).toBeLessThan(Date.now()); }); }); diff --git a/packages/ledger/src/ledger-layer.ts b/packages/ledger/src/ledger-layer.ts index 544fdaf25..56a4c7166 100644 --- a/packages/ledger/src/ledger-layer.ts +++ b/packages/ledger/src/ledger-layer.ts @@ -1,8 +1,9 @@ import { Ledger, type ContentStore, type RunOutcomeMapping, type KeyedProjection } from '@on.auto/operations'; -import { Effect, Layer } from 'effect'; +import { Context, Effect, Layer, Semaphore } from 'effect'; import { signalledOn, type AppendSignal } from './appends/append-signal.ts'; -import { sweptHourly } from './content/content-sweep.ts'; +import { LedgerConnection, type ConnectionPorts } from './connection/ledger-connection.ts'; +import { LedgerChores, orphansSweptOf } from './content/content-sweep.ts'; import type { AppendObserver, LedgerStore } from './event-store.ts'; import { makeLedger } from './ledger-service.ts'; @@ -17,18 +18,32 @@ export interface StoreLayerOptions extends OpenedWith { readonly observer?: AppendObserver | undefined; } -export function ledgerLayerOver(open: () => LedgerStore, { appends, content }: OpenedWith = {}): Layer.Layer { - return Layer.effect( - Ledger, +export interface OpenedLedger { + readonly store: LedgerStore; + readonly connection: ConnectionPorts; +} + +export type LedgerServices = Ledger | LedgerConnection | LedgerChores; + +export function ledgerLayerOver( + open: () => OpenedLedger, + { appends, content }: OpenedWith = {}, +): Layer.Layer { + return Layer.effectContext( Effect.gen(function* () { - const store = yield* Effect.acquireRelease( - Effect.sync(() => signalledOn(open(), appends)), - (opened) => Effect.promise(() => opened.close()), + const sweeping = yield* Semaphore.make(1); + const opened = yield* Effect.acquireRelease(Effect.sync(open), ({ store }) => + sweeping.withPermit(Effect.promise(() => store.close())), ); + const store = signalledOn(opened.store, appends); yield* Effect.promise(() => store.migrate()); const kept = content ?? store; - yield* sweptHourly(kept); - return makeLedger({ ...store, content: kept.content, sweepContent: kept.sweepContent }); + const orphansSwept = orphansSweptOf(kept); + const ledger = makeLedger({ ...store, content: kept.content, sweepContent: kept.sweepContent }); + return Context.make(Ledger, ledger).pipe( + Context.add(LedgerConnection, opened.connection), + Context.add(LedgerChores, { sweepOrphans: (now: number) => sweeping.withPermit(orphansSwept(now)) }), + ); }), ); } diff --git a/packages/ledger/src/ledger.test.ts b/packages/ledger/src/ledger.test.ts index 5c188f790..8cc67e4fe 100644 --- a/packages/ledger/src/ledger.test.ts +++ b/packages/ledger/src/ledger.test.ts @@ -1,7 +1,7 @@ import { sqlite3EventStoreDriver } from '@event-driven-io/emmett-sqlite/sqlite3'; import { describe, onTestFinished } from 'vitest'; -import { sqliteEventStore, sqliteLayout } from './index.ts'; +import { sqliteLayout, sqliteLedgerOpened } from './index.ts'; import { definitionStreamsIndexed, executedOn, planOf, queried } from './recorded/sqlite-checks.ts'; import { keptSecretsBehaviour } from './secrets/kept-secrets-behaviour.ts'; import { ledgerLayer, sqliteSecretRows } from './sqlite3.ts'; @@ -9,6 +9,9 @@ import { ledgerBehaviour } from './testing/ledger-behaviour.ts'; import type { LedgerEntry } from './testing/ledger-entry.ts'; import { temporaryDatabase } from './testing/temporary-database.ts'; +const openedOn: LedgerEntry['openedOn'] = (fileName) => + sqliteLedgerOpened({ optionsOf: () => ({ driver: sqlite3EventStoreDriver, fileName }), inMemory: false }); + const onSQLite: LedgerEntry = { mostEventsInOneAppend: 8, afterClosing: 'Bounded connection pool has been closed', @@ -20,7 +23,8 @@ const onSQLite: LedgerEntry = { }, ledgerOn: (fileName, runOutcomes, projections = []) => ledgerLayer({ fileName, projections, ...(runOutcomes === undefined ? {} : { runOutcomes }) }), - storeOn: (fileName) => sqliteEventStore(() => ({ driver: sqlite3EventStoreDriver, fileName })), + storeOn: (fileName) => openedOn(fileName).store, + openedOn, queried, executedOn, layout: sqliteLayout, diff --git a/packages/ledger/src/postgresql/connections-on-postgresql.test.ts b/packages/ledger/src/postgresql/connections-on-postgresql.test.ts index 40cdd49ff..cfc473418 100644 --- a/packages/ledger/src/postgresql/connections-on-postgresql.test.ts +++ b/packages/ledger/src/postgresql/connections-on-postgresql.test.ts @@ -2,12 +2,12 @@ import { Effect, Function, Layer, Logger, Schema } from 'effect'; import { Client } from 'pg'; import { describe, expect, it, onTestFinished } from 'vitest'; -import { sweepsWatched } from '../content/watched-sweeps.ts'; -import { openLedgerWith } from '../testing/open-ledger.ts'; +import { endingOf, sweepsWatched } from '../content/watched-sweeps.ts'; +import { openChoresWith, openLedgerWith } from '../testing/open-ledger.ts'; import { tally } from '../testing/tally.ts'; import { until } from '../testing/until.ts'; import { lostConnectionsLoggedWith } from './lost-connections.ts'; -import { plainPoolsOn, postgresqlEventStore, postgresqlLedgerLayer } from './postgresql-ledger.ts'; +import { plainPoolsOn, postgresqlLedgerLayer, postgresqlLedgerOpened } from './postgresql-ledger.ts'; import { emmettsMigrationLock } from './postgresql-run-outcomes.ts'; const server = process.env['LEDGER_TEST_POSTGRESQL_URL'] ?? ''; @@ -87,7 +87,7 @@ async function untilNoneOpen(database: string): Promise { function sweptOn(database: string, held?: Promise) { return sweepsWatched( - () => postgresqlEventStore({ pools: plainPoolsOn(database), reportLostConnection: Function.constVoid }), + () => postgresqlLedgerOpened({ pools: plainPoolsOn(database), reportLostConnection: Function.constVoid }), held, ); } @@ -107,10 +107,11 @@ describe.skipIf(skipped)(`A ledger on a PostgreSQL database of its own${notice}` expect(loaded).toEqual({ state: 1, version: 1 }); }); - it('holds one connection once its first sweep is done, and closes every connection when the runtime is disposed', async () => { + it('holds one connection once its chore has swept, and closes every connection when the runtime is disposed', async () => { const database = await aDatabase(); const watched = sweptOn(database); - const { ledger, dispose } = await openLedgerWith(watched.layer); + const { ledger, sweptAt, dispose } = await openChoresWith(watched.layer); + await sweptAt(Date.now()); await watched.finished; await Effect.runPromise(ledger.load('org/acme/tallies', tally)); const whileOpen = await queried(database, connectionsTo(database)); @@ -127,14 +128,15 @@ describe.skipIf(skipped)(`A ledger on PostgreSQL whose runtime is disposed while const database = await aDatabase(); const held = Promise.withResolvers(); const watched = sweptOn(database, held.promise); - const { dispose } = await openLedgerWith(watched.layer); + const { sweptAt, dispose } = await openChoresWith(watched.layer); + const sweeping = endingOf(sweptAt(Date.now())); await watched.started; const disposing = dispose(); held.resolve(); await disposing; - expect(await watched.finished).toBe('swept'); + expect(await Promise.all([watched.finished, sweeping])).toEqual(['swept', 'ended']); await untilNoneOpen(database); }); }); @@ -175,10 +177,11 @@ async function aLedgerLogging(database: string) { ]); const context = await Effect.runPromise(Effect.context().pipe(Effect.provide(logging))); const watched = sweepsWatched(() => - postgresqlEventStore({ pools: plainPoolsOn(database), reportLostConnection: lostConnectionsLoggedWith(context) }), + postgresqlLedgerOpened({ pools: plainPoolsOn(database), reportLostConnection: lostConnectionsLoggedWith(context) }), ); - const { ledger, dispose } = await openLedgerWith(watched.layer.pipe(Layer.provide(logging))); + const { ledger, sweptAt, dispose } = await openChoresWith(watched.layer.pipe(Layer.provide(logging))); onTestFinished(dispose); + await sweptAt(Date.now()); return { ledger, logged, firstSweep: watched.finished }; } diff --git a/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts b/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts index 298ebe44a..8465b06b6 100644 --- a/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts +++ b/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts @@ -12,6 +12,7 @@ import { plainPoolsOn, postgresqlEventStore, postgresqlLedgerLayer, + postgresqlLedgerOpened, postgresqlSecretRows, } from './postgresql-ledger.ts'; @@ -89,6 +90,10 @@ async function aDatabase(): Promise { const lostConnections: Readonly[] = []; +function reportLostConnection(error: Readonly): void { + lostConnections.push(error); +} + const onPostgreSQL: LedgerEntry = { mostEventsInOneAppend: 64, afterClosing: 'Cannot use a pool after calling end on the pool', @@ -100,13 +105,8 @@ const onPostgreSQL: LedgerEntry = { projections, ...(runOutcomes === undefined ? {} : { runOutcomes }), }), - storeOn: (database) => - postgresqlEventStore({ - pools: plainPoolsOn(database), - reportLostConnection: (error) => { - lostConnections.push(error); - }, - }), + storeOn: (database) => postgresqlEventStore({ pools: plainPoolsOn(database), reportLostConnection }), + openedOn: (database) => postgresqlLedgerOpened({ pools: plainPoolsOn(database), reportLostConnection }), queried, executedOn, layout: postgresqlLayout, diff --git a/packages/ledger/src/postgresql/postgresql-connection.test.ts b/packages/ledger/src/postgresql/postgresql-connection.test.ts new file mode 100644 index 000000000..bcf06c477 --- /dev/null +++ b/packages/ledger/src/postgresql/postgresql-connection.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; + +import type { ConnectionPorts } from '../connection/ledger-connection.ts'; +import type { StatementAnswer } from '../connection/statement-answers.ts'; +import { statement, textOnPostgreSQL, type StatementValue } from '../connection/statement.ts'; +import type { LedgerStreams, StoredRecord } from '../event-store.ts'; +import { postgresqlConnectionOn, type Connection } from './postgresql-connection.ts'; + +interface Asked { + readonly text: string; + readonly values: readonly StatementValue[]; +} + +interface Telling { + readonly connection: ConnectionPorts; + readonly asked: () => readonly Asked[]; + readonly said: () => readonly string[]; +} + +const streams: LedgerStreams = { + mostEventsInOneAppend: 64, + pointLength: 2, + read: () => Promise.resolve({ version: 0, messages: [] }), + append: () => Promise.resolve(), + readRecorded: () => Promise.resolve({ records: [] }), + readRecordedEvent: (_, id) => Promise.resolve(new Map().get(id)), + readAppended: () => Promise.resolve({ streams: [], through: ['0', '0'], more: false }), + definitionStreams: () => Promise.resolve([]), +}; + +function telling(failingOn = '', rowsOf = (text: string): readonly unknown[] => [{ answered: text }]): Telling { + const asked: Asked[] = []; + const said: string[] = []; + const answerOf = (text: string): StatementAnswer => { + const rows = rowsOf(text); + return { rows, rowCount: rows.length }; + }; + const connection: Connection = { + query: (text, values = []) => { + asked.push({ text, values }); + return text === failingOn ? Promise.reject(new Error(`${text} failed`)) : Promise.resolve(answerOf(text)); + }, + release: () => { + said.push('connection released'); + }, + }; + return { + connection: postgresqlConnectionOn( + { + query: (text, values) => { + asked.push({ text, values }); + return Promise.resolve(answerOf(text)); + }, + connect: () => Promise.resolve(connection), + }, + streams, + ), + asked: () => asked, + said: () => said, + }; +} + +const tableOfAFolder = statement`CREATE TABLE IF NOT EXISTS folder_rows (playbook_key TEXT NOT NULL PRIMARY KEY)`; + +const indexOfAFolder = statement`CREATE INDEX IF NOT EXISTS folder_rows_by_key ON folder_rows (playbook_key)`; + +describe('the connection of the ledger on PostgreSQL, making the tables beside the ledger', () => { + it('makes them in one transaction under a lock of its own, and keeps them', async () => { + const { connection, asked, said } = telling(); + + await connection.migrated([tableOfAFolder, indexOfAFolder]); + + expect(asked()).toEqual([ + { text: 'BEGIN', values: [] }, + { text: 'SELECT pg_advisory_xact_lock($1)', values: [7_461_239_041] }, + { text: textOnPostgreSQL(tableOfAFolder), values: [] }, + { text: textOnPostgreSQL(indexOfAFolder), values: [] }, + { text: 'COMMIT', values: [] }, + ]); + expect(said()).toEqual(['connection released']); + }); + + it('rolls them back, and gives its connection back, when a table cannot be made', async () => { + const { connection, asked, said } = telling(textOnPostgreSQL(indexOfAFolder)); + + await expect(connection.migrated([tableOfAFolder, indexOfAFolder])).rejects.toThrow( + `${textOnPostgreSQL(indexOfAFolder)} failed`, + ); + expect(asked().slice(-2)).toEqual([ + { text: textOnPostgreSQL(indexOfAFolder), values: [] }, + { text: 'ROLLBACK', values: [] }, + ]); + expect(said()).toEqual(['connection released']); + }); +}); + +describe('the connection of the ledger on PostgreSQL, open', () => { + it('reads and writes with its values numbered, and names its dialect and the streams of the ledger', async () => { + const { connection, asked } = telling(); + + const read = await connection.read(statement`SELECT ${1} AS one`); + const written = await connection.write(statement`DELETE FROM workflow_due WHERE run_key = ${'a'}`); + + expect(asked()).toEqual([ + { text: 'SELECT $1 AS one', values: [1] }, + { text: 'DELETE FROM workflow_due WHERE run_key = $1', values: ['a'] }, + ]); + expect([read, written]).toEqual([ + [{ answered: 'SELECT $1 AS one' }], + [{ answered: 'DELETE FROM workflow_due WHERE run_key = $1' }], + ]); + expect([connection.dialect, connection.streams]).toEqual(['postgresql', streams]); + }); +}); + +const runsMoved = statement`UPDATE workflow_runs SET status = ${'ended'} WHERE run_key = ${'a'}`; + +const checkpointMoved = statement`UPDATE folder_rows SET checkpoint = ${'2'} WHERE playbook_key = ${'a'} AND checkpoint = ${'1'} + RETURNING playbook_key`; + +const transactionAsked: readonly Asked[] = [ + { text: 'BEGIN', values: [] }, + { text: textOnPostgreSQL(runsMoved), values: ['ended', 'a'] }, + { text: textOnPostgreSQL(checkpointMoved), values: ['2', 'a', '1'] }, +]; + +function answeringNothingTo(text: string): (asked: string) => readonly unknown[] { + return (asked) => (asked === text ? [] : [{ answered: asked }]); +} + +describe('the connection of the ledger on PostgreSQL, writing in one transaction', () => { + it('runs the statements in order on one connection and keeps them, answering what the last answers, when the last writes a row', async () => { + const { connection, asked, said } = telling(); + + const written = await connection.transaction([runsMoved, checkpointMoved]); + + expect(asked()).toEqual([...transactionAsked, { text: 'COMMIT', values: [] }]); + expect([written, said().at(-1)]).toEqual([ + [{ answered: textOnPostgreSQL(checkpointMoved) }], + 'connection released', + ]); + }); + + it('keeps none of them when the last writes no row, as when another wrote the row first', async () => { + const { connection, asked } = telling('', answeringNothingTo(textOnPostgreSQL(checkpointMoved))); + + const written = await connection.transaction([runsMoved, checkpointMoved]); + + expect(asked()).toEqual([...transactionAsked, { text: 'ROLLBACK', values: [] }]); + expect(written).toEqual([]); + }); + + it('keeps none of them, and fails with the error of the driver, when one fails midway', async () => { + const { connection, asked, said } = telling(textOnPostgreSQL(checkpointMoved)); + + await expect(connection.transaction([runsMoved, checkpointMoved])).rejects.toThrow( + `${textOnPostgreSQL(checkpointMoved)} failed`, + ); + expect(asked()).toEqual([...transactionAsked, { text: 'ROLLBACK', values: [] }]); + expect(said().at(-1)).toBe('connection released'); + }); +}); + +describe('the connection of the ledger on PostgreSQL, telling the time', () => { + it('reads the clock of the database, which every server on it shares, in milliseconds', async () => { + const { connection, asked } = telling('', () => [{ now: '1790845200000' }]); + + const now = await connection.sharedClock?.(); + + expect(now).toBe(1_790_845_200_000); + expect(asked()).toEqual([{ text: 'SELECT (EXTRACT(EPOCH FROM now()) * 1000)::bigint AS now', values: [] }]); + }); +}); + +const snapshotBegun = 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'; + +const firstRead = statement`SELECT checkpoint FROM folder_rows WHERE playbook_key = ${'a'}`; + +const secondRead = statement`SELECT COUNT(*) AS rows FROM folder_rows`; + +function bothRead({ connection }: Telling) { + return connection.readConsistently((read) => Promise.all([read(firstRead), read(secondRead)])); +} + +describe('the connection of the ledger on PostgreSQL, reading in one snapshot', () => { + it('reads on one connection in a transaction that only reads and sees one snapshot, and ends it keeping nothing', async () => { + const told = telling(); + + const read = await bothRead(told); + + expect(told.asked()).toEqual([ + { text: snapshotBegun, values: [] }, + { text: textOnPostgreSQL(firstRead), values: ['a'] }, + { text: textOnPostgreSQL(secondRead), values: [] }, + { text: 'ROLLBACK', values: [] }, + ]); + expect([read, told.said().at(-1)]).toEqual([ + [[{ answered: textOnPostgreSQL(firstRead) }], [{ answered: textOnPostgreSQL(secondRead) }]], + 'connection released', + ]); + }); + + it('fails with the error of the driver when a read fails, and still ends the snapshot', async () => { + const told = telling(textOnPostgreSQL(secondRead)); + + await expect(bothRead(told)).rejects.toThrow(`${textOnPostgreSQL(secondRead)} failed`); + expect([told.asked().at(-1), told.said().at(-1)]).toEqual([ + { text: 'ROLLBACK', values: [] }, + 'connection released', + ]); + }); + + it.each([ + ['cannot begin', snapshotBegun], + ['cannot be ended', 'ROLLBACK'], + ])('fails, and gives its connection back, when its snapshot %s', async (_when, failingOn) => { + const told = telling(failingOn); + + await expect(bothRead(told)).rejects.toThrow(`${failingOn} failed`); + expect(told.said().at(-1)).toBe('connection released'); + }); +}); diff --git a/packages/ledger/src/postgresql/postgresql-connection.ts b/packages/ledger/src/postgresql/postgresql-connection.ts new file mode 100644 index 000000000..8d6bc56ab --- /dev/null +++ b/packages/ledger/src/postgresql/postgresql-connection.ts @@ -0,0 +1,103 @@ +import { Schema } from 'effect'; + +import type { ConnectionPorts, ReadRows } from '../connection/ledger-connection.ts'; +import { lastAnswerOf, wroteARow, type StatementAnswer } from '../connection/statement-answers.ts'; +import { statement, textOnPostgreSQL, type Statement, type StatementValue } from '../connection/statement.ts'; +import type { LedgerStreams } from '../event-store.ts'; + +export interface Connection { + query(text: string, values?: readonly StatementValue[]): Promise; + release(): void; +} + +export interface Connections { + query(text: string, values: readonly StatementValue[]): Promise; + connect(): Promise; +} + +const migrationLock = 7_461_239_041; + +const NowRows = Schema.Tuple([Schema.Struct({ now: Schema.Union([Schema.Finite, Schema.FiniteFromString]) })]); + +const clockRead = statement`SELECT (EXTRACT(EPOCH FROM now()) * 1000)::bigint AS now`; + +function alwaysKept(): boolean { + return true; +} + +async function inTransaction( + connections: Connections, + statements: readonly Statement[], + isKept: (last: StatementAnswer) => boolean, +): Promise { + const connection = await connections.connect(); + try { + await connection.query('BEGIN'); + const last = await lastAnswerOf(statements, (asked) => connection.query(textOnPostgreSQL(asked), asked.values)); + await connection.query(isKept(last) ? 'COMMIT' : 'ROLLBACK'); + return last; + } catch (failure) { + await connection.query('ROLLBACK'); + throw failure; + } finally { + connection.release(); + } +} + +async function snapshotOn(connections: Connections): Promise { + const connection = await connections.connect(); + try { + await connection.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); + return connection; + } catch (failure) { + connection.release(); + throw failure; + } +} + +async function snapshotEnded(connection: Connection): Promise { + try { + await connection.query('ROLLBACK'); + } finally { + connection.release(); + } +} + +function readsOn(connection: Connection): ReadRows { + return async (asked) => (await connection.query(textOnPostgreSQL(asked), asked.values)).rows; +} + +function snapshotsOn(connections: Connections): ConnectionPorts['readConsistently'] { + const read = async (reads: (read: ReadRows) => Promise): Promise => { + const connection = await snapshotOn(connections); + try { + return await reads(readsOn(connection)); + } finally { + await snapshotEnded(connection); + } + }; + return read; +} + +export function postgresqlConnectionOn(connections: Connections, streams: LedgerStreams): ConnectionPorts { + const query = async (asked: Statement) => (await connections.query(textOnPostgreSQL(asked), asked.values)).rows; + return { + dialect: 'postgresql', + streams, + read: query, + write: query, + transaction: async (statements) => (await inTransaction(connections, statements, wroteARow)).rows, + readConsistently: snapshotsOn(connections), + sharedClock: async () => { + const [{ now }] = Schema.decodeUnknownSync(NowRows)(await query(clockRead)); + return now; + }, + migrated: async (statements) => { + await inTransaction( + connections, + [statement`SELECT pg_advisory_xact_lock(${migrationLock})`, ...statements], + alwaysKept, + ); + }, + }; +} diff --git a/packages/ledger/src/postgresql/postgresql-event-store.ts b/packages/ledger/src/postgresql/postgresql-event-store.ts index e5cbff58b..d1e8060f8 100644 --- a/packages/ledger/src/postgresql/postgresql-event-store.ts +++ b/packages/ledger/src/postgresql/postgresql-event-store.ts @@ -2,10 +2,10 @@ import { getPostgreSQLEventStore } from '@event-driven-io/emmett-postgresql'; import { pgEventStoreDriver } from '@event-driven-io/emmett-postgresql/pg'; import { emmettEventStore } from '../emmett/emmett-event-store.ts'; -import type { LedgerStore } from '../event-store.ts'; +import type { AppendObserver, LedgerStore } from '../event-store.ts'; import { formattedFor, postgresqlRecordedStoreOn, type Query } from '../postgresql-reads/postgresql-recorded.ts'; import { dataAsJsonText } from './json-text.ts'; -import type { PostgreSQLOptions } from './postgresql-options.ts'; +import type { PostgreSQLOptions, PostgreSQLPools } from './postgresql-options.ts'; import { postgresqlProjectionsOf } from './postgresql-projections.ts'; import { postgresqlRunOutcomesOf } from './postgresql-run-outcomes.ts'; import { beforeTheSchema } from './postgresql-schema.ts'; @@ -14,27 +14,28 @@ export interface PostgreSQLStoreOptions extends PostgreSQLOptions { readonly reportLostConnection: (error: Readonly) => void; } +export type PostgreSQLPool = ReturnType; + +export type StoreKept = Pick; + const eventsInOneBoundedAppend = 64; -const connectionsOfOneLedger = 10; +export const connectionsOfOneLedger = 10; -export function postgresqlEventStore({ - pools, - reportLostConnection, - runOutcomes, - projections, - observer, -}: PostgreSQLStoreOptions): LedgerStore { - const pool = pools({ max: connectionsOfOneLedger }); - pool.on('error', reportLostConnection); - const kept = postgresqlProjectionsOf({ runOutcomes, projections }); +export function postgresqlStoreOn( + pooled: () => PostgreSQLPool, + kept: StoreKept, + observer?: AppendObserver, +): LedgerStore { + const pool = pooled(); + const projections = postgresqlProjectionsOf(kept); const store = emmettEventStore( getPostgreSQLEventStore({ driver: pgEventStoreDriver, connectionOptions: { pool }, schema: { autoMigration: 'None' }, - projections: [...kept.registrations], - hooks: { onBeforeSchemaCreated: beforeTheSchema, onAfterSchemaCreated: kept.afterTheSchema }, + projections: [...projections.registrations], + hooks: { onBeforeSchemaCreated: beforeTheSchema, onAfterSchemaCreated: projections.afterTheSchema }, }), { data: dataAsJsonText, mostEventsInOneAppend: eventsInOneBoundedAppend, observer }, ); @@ -43,11 +44,21 @@ export function postgresqlEventStore({ return { ...store, ...postgresqlRecordedStoreOn(query, pool), - ...kept.readerOn({ query: formattedFor(query), command: formattedFor(query) }), - readRunOutcomes: postgresqlRunOutcomesOf(runOutcomes, query), + ...projections.readerOn({ query: formattedFor(query), command: formattedFor(query) }), + readRunOutcomes: postgresqlRunOutcomesOf(kept.runOutcomes, query), close: async () => { await store.close(); await pool.end(); }, }; } + +export function poolOf({ pools, reportLostConnection }: PostgreSQLStoreOptions, max: number): PostgreSQLPool { + const pool = pools({ max }); + pool.on('error', reportLostConnection); + return pool; +} + +export function postgresqlEventStore(options: PostgreSQLStoreOptions): LedgerStore { + return postgresqlStoreOn(() => poolOf(options, connectionsOfOneLedger), options, options.observer); +} diff --git a/packages/ledger/src/postgresql/postgresql-ledger.ts b/packages/ledger/src/postgresql/postgresql-ledger.ts index a97a9a3dc..cd6b0a7f9 100644 --- a/packages/ledger/src/postgresql/postgresql-ledger.ts +++ b/packages/ledger/src/postgresql/postgresql-ledger.ts @@ -1,10 +1,16 @@ -import type { ContentStore, Ledger } from '@on.auto/operations'; +import type { ContentStore } from '@on.auto/operations'; import { Effect, Layer } from 'effect'; import type { AppendSignal } from '../appends/append-signal.ts'; -import { ledgerLayerOver } from '../ledger-layer.ts'; +import { ledgerLayerOver, type LedgerServices, type OpenedLedger } from '../ledger-layer.ts'; import { lostConnectionsLoggedWith } from './lost-connections.ts'; -import { postgresqlEventStore } from './postgresql-event-store.ts'; +import { postgresqlConnectionOn } from './postgresql-connection.ts'; +import { + connectionsOfOneLedger, + poolOf, + postgresqlStoreOn, + type PostgreSQLStoreOptions, +} from './postgresql-event-store.ts'; import type { PostgreSQLOptions } from './postgresql-options.ts'; export { postgresqlEventStore, type PostgreSQLStoreOptions } from './postgresql-event-store.ts'; @@ -20,11 +26,37 @@ export interface PostgreSQLLedgerOptions extends PostgreSQLOptions { readonly content?: ContentStore; } -export function postgresqlLedgerLayer({ appends, content, ...options }: PostgreSQLLedgerOptions): Layer.Layer { +const connectionsForTheStatementsBeside = 4; + +export function postgresqlLedgerOpened(options: PostgreSQLStoreOptions): OpenedLedger { + const { observer } = options; + const pool = poolOf(options, connectionsOfOneLedger); + const besides = poolOf(options, connectionsForTheStatementsBeside); + const store = postgresqlStoreOn(() => pool, options, observer); + return { + store: { + ...store, + close: async () => { + await store.close(); + await besides.end(); + }, + }, + connection: postgresqlConnectionOn( + besides, + postgresqlStoreOn(() => pool, {}, observer), + ), + }; +} + +export function postgresqlLedgerLayer({ + appends, + content, + ...options +}: PostgreSQLLedgerOptions): Layer.Layer { return Layer.unwrap( Effect.map(Effect.context(), (context) => ledgerLayerOver( - () => postgresqlEventStore({ ...options, reportLostConnection: lostConnectionsLoggedWith(context) }), + () => postgresqlLedgerOpened({ ...options, reportLostConnection: lostConnectionsLoggedWith(context) }), { appends, content }, ), ), diff --git a/packages/ledger/src/sqlite-event-store.ts b/packages/ledger/src/sqlite-event-store.ts index 117403c2d..0c9e12553 100644 --- a/packages/ledger/src/sqlite-event-store.ts +++ b/packages/ledger/src/sqlite-event-store.ts @@ -4,7 +4,6 @@ import { getSQLiteEventStore } from '@event-driven-io/emmett-sqlite'; import { dataAsWritten, emmettEventStore } from './emmett/emmett-event-store.ts'; import type { AppendObserver, LedgerStore } from './event-store.ts'; import { sqliteLayoutKept, sqliteSchemaCreated } from './layout/sqlite-schema.ts'; -import { ledgerLayerOver, type StoreLayerOptions } from './ledger-layer.ts'; import { sqliteRunOutcomesOf } from './outcomes/sqlite-run-outcomes.ts'; import type { KeptTables } from './projections/projection-parts.ts'; import { preparedOn, sqliteProjectionsOf } from './projections/sqlite-projections.ts'; @@ -16,18 +15,23 @@ const eventsWithinAHundredParameters = 8; export type SQLiteStoreOptions = Parameters>[0]; +export function pooledOptionsOf(optionsOf: () => SQLiteStoreOptions) { + const options = optionsOf(); + const pool = + options.pool ?? dumbo({ serialization: options.serialization, ...options.driver.mapToDumboOptions(options) }); + return { ...options, pool }; +} + export function sqliteEventStore( optionsOf: () => SQLiteStoreOptions, kept: KeptTables = {}, observer?: AppendObserver, ): LedgerStore { - const options = optionsOf(); - const pool = - options.pool ?? dumbo({ serialization: options.serialization, ...options.driver.mapToDumboOptions(options) }); + const options = pooledOptionsOf(optionsOf); + const { pool } = options; const projections = sqliteProjectionsOf(kept); const store = getSQLiteEventStore({ ...options, - pool, schema: { autoMigration: 'None' }, projections: [...(options.projections ?? []), ...projections.registrations], }); @@ -52,13 +56,3 @@ export function sqliteEventStore( }, }; } - -export function sqliteLedgerLayer( - optionsOf: () => SQLiteStoreOptions, - { runOutcomes, projections, appends, content, observer }: StoreLayerOptions = {}, -) { - return ledgerLayerOver(() => sqliteEventStore(optionsOf, { runOutcomes, projections }, observer), { - appends, - content, - }); -} diff --git a/packages/ledger/src/sqlite3.ts b/packages/ledger/src/sqlite3.ts index 211b00583..587366637 100644 --- a/packages/ledger/src/sqlite3.ts +++ b/packages/ledger/src/sqlite3.ts @@ -2,13 +2,14 @@ import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { sqlite3EventStoreDriver } from '@event-driven-io/emmett-sqlite/sqlite3'; -import type { ContentStore, Ledger, RunOutcomeMapping, KeyedProjection } from '@on.auto/operations'; +import type { ContentStore, RunOutcomeMapping, KeyedProjection } from '@on.auto/operations'; import type { Layer } from 'effect'; import type { AppendSignal } from './appends/append-signal.ts'; +import { sqliteLedgerLayer } from './connection/sqlite-opening.ts'; import type { AppendObserver } from './event-store.ts'; +import type { LedgerServices } from './ledger-layer.ts'; import { sqliteSecretRowsOn, type SecretRowsOptions } from './secrets/sqlite-secret-rows.ts'; -import { sqliteLedgerLayer } from './sqlite-event-store.ts'; export interface LedgerOptions { readonly fileName: string; @@ -47,8 +48,11 @@ export function ledgerLayer({ appends, content, observer, -}: LedgerOptions): Layer.Layer { - return sqliteLedgerLayer(() => sqlite3OptionsOf(fileName), { runOutcomes, projections, appends, content, observer }); +}: LedgerOptions): Layer.Layer { + return sqliteLedgerLayer( + { optionsOf: () => sqlite3OptionsOf(fileName), inMemory: fileName === privateMemory }, + { runOutcomes, projections, appends, content, observer }, + ); } export function sqliteSecretRows({ fileName }: SecretRowsOptions) { diff --git a/packages/ledger/src/stream-reader.ts b/packages/ledger/src/stream-reader.ts index fc7595984..bfc10238f 100644 --- a/packages/ledger/src/stream-reader.ts +++ b/packages/ledger/src/stream-reader.ts @@ -2,10 +2,10 @@ import type { StreamReader } from '@on.auto/operations'; import { Effect } from 'effect'; import { eventCodecOf } from './event-codec.ts'; -import type { StreamStore } from './event-store.ts'; +import type { Streams } from './event-store.ts'; import { foldEvents } from './fold-events.ts'; -export function streamReaderOf(store: StreamStore): StreamReader['load'] { +export function streamReaderOf(store: Streams): StreamReader['load'] { return (stream, fold) => Effect.gen(function* () { const recorded = yield* Effect.promise(() => store.read(stream)); diff --git a/packages/ledger/src/testing/ledger-entry.ts b/packages/ledger/src/testing/ledger-entry.ts index e8b76dc18..6b708e0d0 100644 --- a/packages/ledger/src/testing/ledger-entry.ts +++ b/packages/ledger/src/testing/ledger-entry.ts @@ -4,6 +4,7 @@ import { onTestFinished } from 'vitest'; import type { LedgerStore, RecordedStore, StatementExecutor } from '../event-store.ts'; import type { LayoutDialect } from '../layout/layout-dialects.ts'; +import type { OpenedLedger } from '../ledger-layer.ts'; import { openLedgerWith } from './open-ledger.ts'; export interface LedgerEntry { @@ -17,6 +18,7 @@ export interface LedgerEntry { projections?: readonly KeyedProjection[], ) => Layer.Layer; readonly storeOn: (database: string) => LedgerStore; + readonly openedOn: (database: string) => OpenedLedger; readonly queried: (database: string, statement: string) => Promise; readonly executedOn: (database: string, work: (execute: StatementExecutor) => Promise) => Promise; readonly layout: LayoutDialect; diff --git a/packages/ledger/src/testing/open-ledger.ts b/packages/ledger/src/testing/open-ledger.ts index 34dccd726..a702357f4 100644 --- a/packages/ledger/src/testing/open-ledger.ts +++ b/packages/ledger/src/testing/open-ledger.ts @@ -1,6 +1,8 @@ import { Ledger } from '@on.auto/operations'; import { Effect, ManagedRuntime, type Layer, type Result } from 'effect'; +import { LedgerConnection, type ConnectionPorts } from '../connection/ledger-connection.ts'; +import { LedgerChores } from '../content/content-sweep.ts'; import { ledgerLayer } from '../sqlite3.ts'; export interface OpenLedger { @@ -8,6 +10,14 @@ export interface OpenLedger { readonly dispose: () => Promise; } +export interface OpenConnection extends OpenLedger { + readonly connection: ConnectionPorts; +} + +export interface OpenChores extends OpenLedger { + readonly sweptAt: (now: number) => Promise; +} + export async function openLedgerWith(layer: Layer.Layer): Promise { const runtime = ManagedRuntime.make(layer); const ledger = await runtime.runPromise(Ledger); @@ -18,6 +28,20 @@ export function openLedger(fileName = ':memory:'): Promise { return openLedgerWith(ledgerLayer({ fileName })); } +export async function openConnection(fileName = ':memory:'): Promise { + const runtime = ManagedRuntime.make(ledgerLayer({ fileName })); + const [ledger, connection] = await runtime.runPromise( + Effect.all([Effect.service(Ledger), Effect.service(LedgerConnection)]), + ); + return { ledger, connection, dispose: () => runtime.dispose() }; +} + +export async function openChoresWith(layer: Layer.Layer): Promise { + const runtime = ManagedRuntime.make(layer); + const [ledger, chores] = await runtime.runPromise(Effect.all([Effect.service(Ledger), Effect.service(LedgerChores)])); + return { ledger, sweptAt: (now) => runtime.runPromise(chores.sweepOrphans(now)), dispose: () => runtime.dispose() }; +} + export function outcomeOf(effect: Effect.Effect): Promise> { return Effect.runPromise(Effect.result(effect)); } diff --git a/packages/object-storage/README.md b/packages/object-storage/README.md index c26f33a36..4dcdbd253 100644 --- a/packages/object-storage/README.md +++ b/packages/object-storage/README.md @@ -1,20 +1,36 @@ # @on.auto/object-storage -The content port of [`@on.auto/operations`](../operations/README.md#recorded-content), `RecordedContent`, kept on an object store that speaks the S3 API, the second adapter of the port beside the ledger's own database. The server chooses it when `CONTENT_STORE_URL` is set, checks its bucket at start, and hands it to the ledger as its content store; the ledger keeps every other record in its database as before, and the database adapter stays the default. This package holds the adapter alone, so the ledger depends on no part of the AWS SDK. +The content port of [`@on.auto/operations`](../operations/README.md#recorded-content), `RecordedContent`, kept on the objects of a bucket, the second adapter of the port beside the ledger's own database. The content store speaks to a port of its own, `Bucket` (see [The bucket](#the-bucket)), whose one implementation is an object store that speaks the S3 API. The server chooses it when `CONTENT_STORE_URL` is set, checks its bucket at start, and hands it to the ledger as its content store; the ledger keeps every other record in its database as before, and the database adapter stays the default. This package holds the adapter alone, so the ledger depends on no part of the AWS SDK. ## Entry -`@on.auto/object-storage` (`src/index.ts`) exports three functions and a failure, and `@on.auto/object-storage/location` (`src/location/content-location.ts`) a fourth, a subpath of its own so that the settings of the server read `CONTENT_STORE_URL` without loading the S3 client, which the entry of the adapter reaches (`src/location/location-entry.test.ts`): +`@on.auto/object-storage` (`src/index.ts`) exports four functions, a failure and the `Bucket` port, and `@on.auto/object-storage/location` (`src/location/content-location.ts`) a fifth, a subpath of its own so that the settings of the server read `CONTENT_STORE_URL` without loading the S3 client, which the entry of the adapter reaches (`src/location/location-entry.test.ts`): - `contentLocationOf(url)`, of `@on.auto/object-storage/location`, reads `CONTENT_STORE_URL`, an `s3://bucket/prefix` URL, into `{ bucket, prefix }`, or nothing for a URL that is not one: the scheme is `s3:`, the bucket follows the S3 naming rule, 3 to 63 lowercase letters, digits, dots and hyphens, and the URL names no user, password, port, query or fragment. The prefix is the path without its leading, trailing and doubled slashes, and may be empty. - `storageClientOf({ endpoint, region, proxy }, credentials, waits?)` makes the `S3Client`. A custom endpoint is addressed by path, `http://host:9000/bucket/key`, as S3-compatible servers expect; without one the client addresses the S3 endpoint of its region by host. Without a region the SDK finds one by its own chain, and fails the first request when it finds none. With the environment proxy on, `NODE_USE_ENV_PROXY=1`, the client's requests go through the proxy as the credential chain's do, through `awsClientSettingsOf` of [`@on.auto/outbound/aws`](../outbound/README.md#aws-clients). A connection waits at most 5 s to open and a request at most 60 s for its answer, after which it fails as a `TimeoutError`, so a store that stops answering never holds a check, a put, a get or the cleanup of a failed put; `waits` sets other bounds, which the tests use. The client computes a request checksum and checks a response checksum only where the S3 API requires one, `WHEN_REQUIRED`, since the SDK's default of a checksum on every request is one many S3-compatible servers refuse; the content's own SHA-256 proves every byte read back. - `objectStoreReached(client, location)` asks the store for the bucket with `HeadBucket` and fails with `ObjectStoreUnreachable`, `{ bucket, answer }`, when it cannot: the answer is the store's, its name and status, such as `NotFound (404)` or `AccessDenied (403)`, or the failure of the request itself, such as `Error: connect ECONNREFUSED 127.0.0.1:9000` or `Error: Region is missing`; it carries no credential. -- `objectContentOn(client, location, waits?)` answers a `ContentStore` of `@on.auto/operations`, `{ content, sweepContent }`: `content`, the port, and `sweepContent(before)`, the sweep of uploads below. `client` is a function that answers the client, so that nothing passes the client itself, whose fields are not read-only, as a parameter. +- `bucketOnS3({ client, name }, waits?)` answers the `Bucket` of the bucket `name` on the store the client reaches. `client` is a function that answers the client, so that nothing passes the client itself, whose fields are not read-only, as a parameter. +- `objectContentOn(bucket, location)` answers a `ContentStore` of `@on.auto/operations`, `{ content, sweepContent }`, kept on any `Bucket` under the prefix of the location: `content`, the port, and `sweepContent(before)`, the sweep of uploads below. -The server composes them in `packages/server/src/composition/content-store.ts`: the location and the endpoint from the content store settings, the region from `AWS_REGION`, the environment proxy as the server reads it once for every client it makes, and the credentials from `awsCredentialChainOf` of `@on.auto/outbound`, the default credential chain of the AWS SDK that the Bedrock provider of the reasoning capability uses too, loaded with the adapter. It imports the entry of the adapter only when the settings name a bucket, so a server whose content is kept in the ledger never loads `@aws-sdk/client-s3` and `@aws-sdk/lib-storage`, which were 26 files and 0.7 MiB of its start, and 11 ms of it in a CPU profile (`packages/server/src/composition/content-entries.test.ts`; measured on 2026-10-23, at the median of 11 starts and of 7 profiles). Before the ledger opens, `packages/server/src/composition/ledger-store.ts` checks the bucket with `objectStoreReached`, and a bucket it cannot reach stops the server at start, naming `CONTENT_STORE_URL`, the bucket, the endpoint's host and the store's answer. +The server composes them in `packages/server/src/composition/content-store.ts`, the content store over `bucketOnS3`: the location and the endpoint from the content store settings, the region from `AWS_REGION`, the environment proxy as the server reads it once for every client it makes, and the credentials from `awsCredentialChainOf` of `@on.auto/outbound`, the default credential chain of the AWS SDK that the Bedrock provider of the reasoning capability uses too, loaded with the adapter. It imports the entry of the adapter only when the settings name a bucket, so a server whose content is kept in the ledger never loads `@aws-sdk/client-s3` and `@aws-sdk/lib-storage`, which were 26 files and 0.7 MiB of its start, and 11 ms of it in a CPU profile (`packages/server/src/composition/content-entries.test.ts`; measured on 2026-10-23, at the median of 11 starts and of 7 profiles). Before the ledger opens, `packages/server/src/composition/ledger-store.ts` checks the bucket with `objectStoreReached`, and a bucket it cannot reach stops the server at start, naming `CONTENT_STORE_URL`, the bucket, the endpoint's host and the store's answer. `@on.auto/object-storage/testing` (`src/testing/index.ts`) is the entry the tests of this package and of the server take a real S3-compatible server through, so the reading of its URL and the making of its bucket are written once: `testObjectStoreOf(url)` reads `http://access:secret@host:port/bucket` into the bucket, with a client of its own, the endpoint and the credentials; `bucketReady(bucket)` makes the bucket when it is missing, and finds it when another test made it at the same moment; `keysIn(bucket, prefix)` and `openUploadsIn(bucket, prefix)` list what a prefix holds, and `emptied(bucket, prefix)` aborts and deletes it. +## The bucket + +`Bucket` (`src/bucket/bucket.ts`) is what the content store asks of a store of objects, and all it asks, so that a store that does not speak the S3 API can stand behind it: + +| Method | What it does | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `put(key, body)` | writes an object of bytes in hand, `{ data, mediaType?, ifAbsent? }`, of a stream, `{ stream, mediaType }`, or of the bytes of another object of the bucket, `{ copyOf, ifAbsent? }`, and answers whether it wrote it: `false` when `ifAbsent` found an object at the key | +| `get(key)` | the object's `stream`, read as it is pulled, its size in `bytes` and its `mediaType`, or nothing when there is none | +| `head(key)` | the object's size and media type, or nothing | +| `delete(keys)` | deletes the objects, any number of them | +| `list(prefix)` | the keys under a prefix, in the byte order of the keys | +| `unfinished(prefix)` | the puts of a stream under a prefix that never finished, each with its `key` and an `abandon()`, which ends it and lets go of what it wrote | + +`bucketOnS3` (`src/s3/s3-bucket.ts`) answers it with the S3 API: a put of bytes is a `PutObject`, conditional on `If-None-Match: *` with `ifAbsent`, a put of a stream an `Upload` of `@aws-sdk/lib-storage`, a put of another object a `CopyObject`, conditional the same way, a get a `GetObject` whose body it hands on through the wait below, a head a `HeadObject`, a delete a `DeleteObjects` a thousand keys to a request, a listing the pages of `ListObjectsV2`, and the puts that never finished the open multipart uploads of `ListMultipartUploads` under the prefix, each abandoned with `AbortMultipartUpload`. The digest of what is read back and every rule of the keys are the content store's, over any bucket. + ## The keys | Key | What it holds | @@ -32,14 +48,14 @@ The server composes them in `packages/server/src/composition/content-store.ts`: - A put whose expected digest the playbook holds reads its stream through the digest, writes nothing, and answers the object it found. - A content of one chunk is still in hand at the end of its stream. When the playbook holds its digest, the put writes nothing and answers the object it found, as the database adapter does; otherwise it writes the chunk under its digest with `If-None-Match: *`, so a second put of the digest that wrote first stands, and the put answers that one's media type and size. -- A larger content is uploaded while it is read, under `/uploads/`, through `Upload` of `@aws-sdk/lib-storage`: as one `PutObject` when it fits a part and as a multipart upload in parts of 5 MiB, the least the S3 API takes, two at once, otherwise. The upload finishes, the expected digest and size are checked, and the put looks for the object of the digest: when one is held it deletes its upload and answers the one held, so the first media type stands and nothing is written over it; otherwise it copies the upload under the digest with `CopyObject` and `If-None-Match: *` and deletes the upload. A copy refused because another put of the digest copied first answers that one. `CopyObject` copies at most 5 GiB, so a content larger than that fails as a defect once its upload is done, and its upload is deleted. +- A larger content is uploaded while it is read, under `/uploads/`, as a put of a stream, which `bucketOnS3` sends through `Upload` of `@aws-sdk/lib-storage`: as one `PutObject` when it fits a part and as a multipart upload in parts of 5 MiB, the least the S3 API takes, two at once, otherwise. The upload finishes, the expected digest and size are checked, and the put looks for the object of the digest: when one is held it deletes its upload and answers the one held, so the first media type stands and nothing is written over it; otherwise it copies the upload under the digest with `CopyObject` and `If-None-Match: *` and deletes the upload. A copy refused because another put of the digest copied first answers that one. `CopyObject` copies at most 5 GiB, so a content larger than that fails as a defect once its upload is done, and its upload is deleted. - A conditional write the store answers with `409 ConditionalRequestConflict`, which S3 answers when another write or a delete of the key lands during it, is made again, three times in all, before it fails the put as a defect. - A store that ignores the condition on a copy, as MinIO and versitygw do, copies the same bytes over the first: of two puts of one digest made at the same moment with two media types, the later one's media type is left over identical bytes, after the earlier put answered a media type the store no longer holds. A put of a digest held is never copied, so a later put does not do this. - A put refused, or failed by its stream or by its store, deletes its upload; `Upload` aborts a multipart upload it began. A failure of the store fails the put as a defect, as the database adapter fails on its database. ## A read -`content.get(playbook, sha256)` asks for the object with `GetObject` and answers `{ stream, bytes, mediaType }`, or nothing when the object does not exist; any other answer of the store fails the read as a defect. The stream is the object's body read through `verifiedStream` of `@on.auto/operations`, the verification the database adapter reads its chunks through: it digests every piece, hands over the piece that reaches the object's size only once the size and the digest are checked, and fails the stream as a defect with `notTheBytesOfItsDigest` otherwise, so no reader takes every byte of an object that is not its digest's. A store that sends no piece of the body for the wait of a request, 60 s, fails the stream and lets go of the body; the wait runs only while the stream waits for the store, never while its reader holds it back. +`content.get(playbook, sha256)` asks the bucket for the object, with `GetObject` on S3, and answers `{ stream, bytes, mediaType }`, or nothing when the object does not exist; any other answer of the store fails the read as a defect. The stream is the object's body read through `verifiedStream` of `@on.auto/operations`, the verification the database adapter reads its chunks through: it digests every piece, hands over the piece that reaches the object's size only once the size and the digest are checked, and fails the stream as a defect with `notTheBytesOfItsDigest` otherwise, so no reader takes every byte of an object that is not its digest's. A store that sends no piece of the body for the wait of a request, 60 s, fails the stream and lets go of the body; the wait runs only while the stream waits for the store, never while its reader holds it back. ## Staging @@ -47,7 +63,7 @@ The server composes them in `packages/server/src/composition/content-store.ts`: ## The sweep of uploads -An upload can outlive its put: a process that stops between the upload and its delete leaves it, and a multipart upload begun before the stop stays open. `sweepContent(before)` deletes every object under `/uploads/` whose UUIDv7 time is before the time it is given, `uuidV7Before` of `@on.auto/operations`, reading the listing in key order, which is the order of the time a UUIDv7 carries in its first 48 bits, and stopping at the first upload begun after it. It then lists the open multipart uploads of the whole bucket, since MinIO lists none by a prefix, keeps those whose key is under `/uploads/`, and aborts each that began before that time. The ledger runs it on the schedule of its own sweep, at the open and every hour, with the time an hour before, and waits for a sweep under way before it closes, as the [ledger README](../ledger/README.md#recorded-content) says. +An upload can outlive its put: a process that stops between the upload and its delete leaves it, and a multipart upload begun before the stop stays open. `sweepContent(before)` deletes every object under `/uploads/` whose UUIDv7 time is before the time it is given, `uuidV7Before` of `@on.auto/operations`, reading the listing in key order, which is the order of the time a UUIDv7 carries in its first 48 bits, and stopping at the first upload begun after it. It then asks the bucket for the puts under `/uploads/` that never finished, and abandons each that began before that time; on S3 those are the open multipart uploads, which `bucketOnS3` lists for the whole bucket, since MinIO lists none by a prefix, keeping those whose key is under the prefix. The ledger's chore of the sweep runs it, whenever the clock of what runs the ledger calls it, with the time an hour before, and the ledger waits for a sweep under way before it closes, as the [ledger README](../ledger/README.md#recorded-content) says. ## What the bucket needs @@ -90,4 +106,4 @@ CI runs it against RustFS, an S3-compatible server whose image runs from its env ## Source -`src/location` holds the URL and the keys, `src/client` the client, `src/content` the adapter, and `src/testing` the fake bucket, the suites and the entry of the tests. +`src/location` holds the URL and the keys, `src/client` the client, `src/bucket` the port of a bucket, `src/s3` its implementation on the S3 API, its requests and the check of the bucket, `src/content` the adapter over the port, and `src/testing` the fake bucket, the suites and the entry of the tests. diff --git a/packages/object-storage/measure/object-content.ts b/packages/object-storage/measure/object-content.ts index 132de21d0..fa6e7265f 100644 --- a/packages/object-storage/measure/object-content.ts +++ b/packages/object-storage/measure/object-content.ts @@ -1,6 +1,7 @@ import { contentTableHead, measuredContent } from '@on.auto/ledger/content-measurement'; import { objectContentOn } from '../src/content/object-content.ts'; +import { bucketOnS3 } from '../src/s3/s3-bucket.ts'; import { bucketReady, emptied, testObjectStoreOf } from '../src/testing/test-object-store.ts'; const { bucket } = testObjectStoreOf( @@ -11,6 +12,6 @@ const location = { bucket: bucket.name, prefix: `content-measure/${String(Date.n await bucketReady(bucket); contentTableHead(); -const held = await measuredContent('Object storage', objectContentOn(bucket.client, location).content); +const held = await measuredContent('Object storage', objectContentOn(bucketOnS3(bucket), location).content); process.stdout.write(`\n${held}\n`); await emptied(bucket, location.prefix); diff --git a/packages/object-storage/src/bucket/bucket.ts b/packages/object-storage/src/bucket/bucket.ts new file mode 100644 index 000000000..d10a65667 --- /dev/null +++ b/packages/object-storage/src/bucket/bucket.ts @@ -0,0 +1,27 @@ +export interface ObjectHead { + readonly bytes: number; + readonly mediaType: string; +} + +export interface ObjectRead extends ObjectHead { + readonly stream: ReadableStream; +} + +export type ObjectBody = + | { readonly data: Readonly; readonly mediaType?: string; readonly ifAbsent?: boolean } + | { readonly stream: ReadableStream; readonly mediaType: string } + | { readonly copyOf: string; readonly ifAbsent?: boolean }; + +export interface UnfinishedPut { + readonly key: string; + readonly abandon: () => Promise; +} + +export interface Bucket { + readonly put: (key: string, body: ObjectBody) => Promise; + readonly get: (key: string) => Promise; + readonly head: (key: string) => Promise; + readonly delete: (keys: readonly string[]) => Promise; + readonly list: (prefix: string) => AsyncIterable; + readonly unfinished: (prefix: string) => AsyncIterable; +} diff --git a/packages/object-storage/src/client/storage-client.test.ts b/packages/object-storage/src/client/storage-client.test.ts index 25fa5b403..bedfe8b8e 100644 --- a/packages/object-storage/src/client/storage-client.test.ts +++ b/packages/object-storage/src/client/storage-client.test.ts @@ -6,7 +6,9 @@ import { alpha, generated, refusalOf } from '@on.auto/operations/testing'; import { Effect, Function, Schema } from 'effect'; import { describe, expect, it, onTestFinished } from 'vitest'; -import { objectContentOn, objectStoreReached } from '../content/object-content.ts'; +import { objectContentOn } from '../content/object-content.ts'; +import { objectStoreReached } from '../s3/object-store-reached.ts'; +import { bucketOnS3 } from '../s3/s3-bucket.ts'; import { storageClientOf, storageWaits } from './storage-client.ts'; const decodeAddress = Schema.decodeUnknownSync(Schema.Struct({ port: Schema.Number })); @@ -73,7 +75,7 @@ describe('the client of an object store that stops answering', () => { requestMs: 100, }); const location = { bucket: 'content', prefix: '' }; - const { content } = objectContentOn(() => client, location); + const { content } = objectContentOn(bucketOnS3({ client: () => client, name: location.bucket }), location); const started = performance.now(); const refused = await refusalOf(objectStoreReached(() => client, location)); diff --git a/packages/object-storage/src/content/object-content.ts b/packages/object-storage/src/content/object-content.ts index 9600d1b1b..de7dc0714 100644 --- a/packages/object-storage/src/content/object-content.ts +++ b/packages/object-storage/src/content/object-content.ts @@ -1,46 +1,22 @@ -import { S3ServiceException, type S3Client } from '@aws-sdk/client-s3'; -import type { ContentStore } from '@on.auto/operations'; -import { Data, Effect } from 'effect'; +import { verifiedStream, type ContentStore, type StreamedContent } from '@on.auto/operations'; +import { Effect } from 'effect'; -import { storageWaits, type StorageWaits } from '../client/storage-client.ts'; +import type { Bucket } from '../bucket/bucket.ts'; import type { ContentLocation } from '../location/content-location.ts'; import { objectKeysOf } from '../location/object-keys.ts'; -import { bucketReached, readAt } from './bucket-requests.ts'; import { stagedOn, unstagedOn } from './object-staging.ts'; import { placedOn, type Placing } from './object-writes.ts'; import { sweptUploads } from './upload-sweep.ts'; -export class ObjectStoreUnreachable extends Data.TaggedError('object_store_unreachable')<{ - readonly bucket: string; - readonly answer: string; -}> {} - -function answerOf(failure: unknown): string { - if (failure instanceof S3ServiceException) { - return `${failure.name} (${String(failure.$metadata.httpStatusCode)})`; - } - return String(failure); -} - -export function objectStoreReached( - client: () => S3Client, - { bucket }: ContentLocation, -): Effect.Effect { - return Effect.tryPromise({ - try: () => bucketReached({ client, name: bucket }), - catch: (failure) => new ObjectStoreUnreachable({ bucket, answer: answerOf(failure) }), - }); +async function verifiedAt(bucket: Bucket, key: string, sha256: string): Promise { + const read = await bucket.get(key); + return read === undefined ? undefined : { ...read, stream: verifiedStream(sha256, read.bytes, read.stream) }; } -export function objectContentOn( - client: () => S3Client, - location: ContentLocation, - { requestMs }: StorageWaits = storageWaits, -): ContentStore { - const placing: Placing = { bucket: { client, name: location.bucket }, keys: objectKeysOf(location) }; - const { bucket, keys } = placing; - const readOf = (key: string, sha256: string) => - Effect.promise(() => readAt(bucket, key, { sha256, waitMs: requestMs })); +export function objectContentOn(bucket: Bucket, location: ContentLocation): ContentStore { + const placing: Placing = { bucket, keys: objectKeysOf(location) }; + const { keys } = placing; + const readOf = (key: string, sha256: string) => Effect.promise(() => verifiedAt(bucket, key, sha256)); return { content: { put: (playbook, content) => placedOn(placing, (sha256) => keys.kept(playbook, sha256), content), diff --git a/packages/object-storage/src/content/object-failures.test.ts b/packages/object-storage/src/content/object-failures.test.ts index 800b16027..8958c8562 100644 --- a/packages/object-storage/src/content/object-failures.test.ts +++ b/packages/object-storage/src/content/object-failures.test.ts @@ -4,8 +4,8 @@ import { alpha, generated, generatedStream, octets, refusalOf } from '@on.auto/o import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; +import { uploadPartBytes } from '../s3/s3-bucket.ts'; import { fakeContentStore } from '../testing/fake-bucket.ts'; -import { uploadPartBytes } from './upload-writing.ts'; const location = { bucket: 'content', prefix: 'playbooks' }; diff --git a/packages/object-storage/src/content/object-reads.test.ts b/packages/object-storage/src/content/object-reads.test.ts index 0683c7655..f61357e41 100644 --- a/packages/object-storage/src/content/object-reads.test.ts +++ b/packages/object-storage/src/content/object-reads.test.ts @@ -5,9 +5,9 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { objectKeysOf } from '../location/object-keys.ts'; +import { uploadPartBytes } from '../s3/s3-bucket.ts'; import { fakeContentStore, type FakeContentStore } from '../testing/fake-bucket.ts'; import { openUploadsIn } from '../testing/test-object-store.ts'; -import { uploadPartBytes } from './upload-writing.ts'; const location = { bucket: 'content', prefix: 'playbooks' }; diff --git a/packages/object-storage/src/content/object-staging.ts b/packages/object-storage/src/content/object-staging.ts index c0ce7dac0..15ba266a8 100644 --- a/packages/object-storage/src/content/object-staging.ts +++ b/packages/object-storage/src/content/object-staging.ts @@ -9,7 +9,6 @@ import { } from '@on.auto/operations'; import { Effect } from 'effect'; -import { heldAt, keysUnder, marked, removed } from './bucket-requests.ts'; import { placedOn, type Placing } from './object-writes.ts'; interface StagedRun { @@ -18,7 +17,7 @@ interface StagedRun { } function endedOf({ bucket, keys }: Placing, { playbook, run }: StagedRun): Effect.Effect { - return Effect.promise(async () => (await heldAt(bucket, keys.ended(playbook, run))) !== undefined); + return Effect.promise(async () => (await bucket.head(keys.ended(playbook, run))) !== undefined); } function stagingOpen(placing: Placing, staged: StagedRun): Effect.Effect { @@ -38,7 +37,7 @@ export function stagedOn( yield* stagingOpen(placing, staged).pipe(Effect.tapError(() => leftUnread(content.stream))); const kept = yield* placedOn(placing, (sha256) => keys.staged(playbook, run, sha256), content); if (yield* endedOf(placing, staged)) { - yield* Effect.promise(() => removed(bucket, [keys.staged(playbook, run, kept.sha256)])); + yield* Effect.promise(() => bucket.delete([keys.staged(playbook, run, kept.sha256)])); return yield* Effect.fail(stagingEnded(run)); } return kept; @@ -47,12 +46,12 @@ export function stagedOn( export async function unstagedOn({ bucket, keys }: Placing, { playbook, run }: StagedRun): Promise { const ended = keys.ended(playbook, run); - await marked(bucket, ended); + await bucket.put(ended, { data: new Uint8Array() }); const staged: string[] = []; - for await (const key of keysUnder(bucket, keys.run(playbook, run))) { + for await (const key of bucket.list(keys.run(playbook, run))) { if (key !== ended) { staged.push(key); } } - await removed(bucket, staged); + await bucket.delete(staged); } diff --git a/packages/object-storage/src/content/object-writes.test.ts b/packages/object-storage/src/content/object-writes.test.ts index 7c17dab4f..769ae1e4e 100644 --- a/packages/object-storage/src/content/object-writes.test.ts +++ b/packages/object-storage/src/content/object-writes.test.ts @@ -5,8 +5,8 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { objectKeysOf } from '../location/object-keys.ts'; +import { uploadPartBytes } from '../s3/s3-bucket.ts'; import { fakeContentStore, type FakeContentStore } from '../testing/fake-bucket.ts'; -import { uploadPartBytes } from './upload-writing.ts'; const location = { bucket: 'content', prefix: 'playbooks' }; diff --git a/packages/object-storage/src/content/object-writes.ts b/packages/object-storage/src/content/object-writes.ts index a5aa77ccf..aeeff92ee 100644 --- a/packages/object-storage/src/content/object-writes.ts +++ b/packages/object-storage/src/content/object-writes.ts @@ -10,8 +10,8 @@ import { } from '@on.auto/operations'; import { Effect } from 'effect'; +import type { Bucket, ObjectHead } from '../bucket/bucket.ts'; import type { ObjectKeys } from '../location/object-keys.ts'; -import { copiedFirst, heldAt, removed, writtenFirst, type Bucket, type Held } from './bucket-requests.ts'; import { uploadWritingOf, type UploadWriting } from './upload-writing.ts'; export interface Placing { @@ -28,8 +28,8 @@ interface Written { readonly upload: string; } -function heldOf(bucket: Bucket, key: string): Effect.Effect { - return Effect.promise(() => heldAt(bucket, key)); +function heldOf(bucket: Bucket, key: string): Effect.Effect { + return Effect.promise(() => bucket.head(key)); } function keptAt(bucket: Bucket, key: string, sha256: string): Effect.Effect { @@ -47,11 +47,11 @@ function readAlone(): Effect.Effect { function placedFirst(bucket: Bucket, key: string, { mediaType, writing, upload }: Written): Effect.Effect { return writing.uploading() ? Effect.promise(async () => { - const first = await copiedFirst(bucket, upload, key); - await removed(bucket, [upload]); + const first = await bucket.put(key, { copyOf: upload, ifAbsent: true }); + await bucket.delete([upload]); return first; }) - : Effect.promise(() => writtenFirst(bucket, key, { body: writing.inHand(), mediaType })); + : Effect.promise(() => bucket.put(key, { data: writing.inHand(), mediaType, ifAbsent: true })); } function keptAfter(bucket: Bucket, keyOf: KeyOf, written: Written): Effect.Effect { @@ -60,7 +60,7 @@ function keptAfter(bucket: Bucket, keyOf: KeyOf, written: Written): Effect.Effec return Effect.gen(function* () { const held = yield* heldOf(bucket, key); if (held !== undefined) { - yield* Effect.promise(() => removed(bucket, written.writing.uploading() ? [written.upload] : [])); + yield* Effect.promise(() => bucket.delete(written.writing.uploading() ? [written.upload] : [])); return { sha256, ...held }; } const first = yield* placedFirst(bucket, key, written); @@ -68,7 +68,10 @@ function keptAfter(bucket: Bucket, keyOf: KeyOf, written: Written): Effect.Effec }); } -function heldDigestRead({ stream, expected }: ContentToKeep, held: Held): Effect.Effect { +function heldDigestRead( + { stream, expected }: ContentToKeep, + held: ObjectHead, +): Effect.Effect { return Effect.gen(function* () { const taken = yield* takenInChunks(stream, mostContentChunkBytes, readAlone); yield* requireExpected(expected, taken); diff --git a/packages/object-storage/src/content/stalled-reads.test.ts b/packages/object-storage/src/content/stalled-reads.test.ts index a2ed79c67..19729a454 100644 --- a/packages/object-storage/src/content/stalled-reads.test.ts +++ b/packages/object-storage/src/content/stalled-reads.test.ts @@ -8,19 +8,19 @@ import { describe, expect, it } from 'vitest'; import { storageWaits } from '../client/storage-client.ts'; import { objectKeysOf } from '../location/object-keys.ts'; -import { fakeBucket } from '../testing/fake-bucket.ts'; -import { objectContentOn } from './object-content.ts'; +import { fakeContentStore } from '../testing/fake-bucket.ts'; const location = { bucket: 'content', prefix: 'playbooks' }; describe('a read on object storage whose store stops sending', () => { it('fails once the store has sent no piece for the wait of a request, and lets go of the body', async () => { - const bucket = fakeBucket(); - const client = bucket.client(); - const store = objectContentOn(() => client, location, { ...storageWaits, requestMs: 100 }); + const store = fakeContentStore(location, { waits: { ...storageWaits, requestMs: 100 } }); const kept = await resultOf(store.content.put(alpha, textToKeep('stalled', octets))); const silent = new Readable({ read: Function.constVoid }); - bucket.intercepting('GetObjectCommand', async (asked) => ({ ...(await asked()), Body: sdkStreamMixin(silent) })); + store.bucket.intercepting('GetObjectCommand', async (asked) => ({ + ...(await asked()), + Body: sdkStreamMixin(silent), + })); const read = await present(store.content.get(alpha, kept.sha256)); const delivered = await deliveredBeforeFailing(read.stream); @@ -35,12 +35,13 @@ describe('a read on object storage whose store stops sending', () => { }); it('lets go of the body of an object its reader leaves', async () => { - const bucket = fakeBucket(); - const client = bucket.client(); - const store = objectContentOn(() => client, location); + const store = fakeContentStore(location); const kept = await resultOf(store.content.put(alpha, textToKeep('left', octets))); const silent = new Readable({ read: Function.constVoid }); - bucket.intercepting('GetObjectCommand', async (asked) => ({ ...(await asked()), Body: sdkStreamMixin(silent) })); + store.bucket.intercepting('GetObjectCommand', async (asked) => ({ + ...(await asked()), + Body: sdkStreamMixin(silent), + })); const read = await present(store.content.get(alpha, kept.sha256)); await read.stream.cancel('The reader left'); diff --git a/packages/object-storage/src/content/upload-sweep.ts b/packages/object-storage/src/content/upload-sweep.ts index 9762fa9db..8d3cb66bd 100644 --- a/packages/object-storage/src/content/upload-sweep.ts +++ b/packages/object-storage/src/content/upload-sweep.ts @@ -1,6 +1,6 @@ import { uuidV7Before } from '@on.auto/operations'; -import { keysUnder, removed, uploadAbandoned, uploadsUnder, type Bucket } from './bucket-requests.ts'; +import type { Bucket } from '../bucket/bucket.ts'; function startedBefore(key: string, before: number): boolean { return uuidV7Before(key.slice(key.lastIndexOf('/') + 1), before); @@ -8,7 +8,7 @@ function startedBefore(key: string, before: number): boolean { async function staleObjectsUnder(bucket: Bucket, uploads: string, before: number): Promise { const stale: string[] = []; - for await (const key of keysUnder(bucket, uploads)) { + for await (const key of bucket.list(uploads)) { if (!startedBefore(key, before)) { break; } @@ -18,10 +18,10 @@ async function staleObjectsUnder(bucket: Bucket, uploads: string, before: number } export async function sweptUploads(bucket: Bucket, uploads: string, before: number): Promise { - await removed(bucket, await staleObjectsUnder(bucket, uploads, before)); - for await (const open of uploadsUnder(bucket, uploads)) { - if (startedBefore(open.key, before)) { - await uploadAbandoned(bucket, open); + await bucket.delete(await staleObjectsUnder(bucket, uploads, before)); + for await (const unfinished of bucket.unfinished(uploads)) { + if (startedBefore(unfinished.key, before)) { + await unfinished.abandon(); } } } diff --git a/packages/object-storage/src/content/upload-writing.ts b/packages/object-storage/src/content/upload-writing.ts index f57093ad5..5a1417ff4 100644 --- a/packages/object-storage/src/content/upload-writing.ts +++ b/packages/object-storage/src/content/upload-writing.ts @@ -1,12 +1,7 @@ -import { Upload } from '@aws-sdk/lib-storage'; import type { TakeChunk } from '@on.auto/operations'; import { Effect, Function } from 'effect'; -import { removed, type Bucket } from './bucket-requests.ts'; - -export const uploadPartBytes = 5 * 1_048_576; - -const uploadPartsAtOnce = 2; +import type { Bucket } from '../bucket/bucket.ts'; export interface UploadWriting { readonly take: TakeChunk; @@ -46,13 +41,7 @@ function whileSending({ settled }: Sending, work: Promise): Promise { function sendingTo({ bucket, key, mediaType }: UploadTarget): Sending { const body = new TransformStream(); - const upload = new Upload({ - client: bucket.client(), - params: { Bucket: bucket.name, Key: key, Body: body.readable, ContentType: mediaType }, - partSize: uploadPartBytes, - queueSize: uploadPartsAtOnce, - }); - const settled = upload.done().then( + const settled = bucket.put(key, { stream: body.readable, mediaType }).then( (): Settled => ({ sent: true }), (failure: unknown): Settled => ({ sent: false, failure }), ); @@ -92,7 +81,7 @@ export function uploadWritingOf(target: UploadTarget): UploadWriting { if (sending !== undefined) { void sending.body.abort(new Error('The content was not kept')).catch(Function.constVoid); await sending.settled; - await removed(target.bucket, [target.key]); + await target.bucket.delete([target.key]); } }), }; diff --git a/packages/object-storage/src/index.ts b/packages/object-storage/src/index.ts index b4b162fa0..0fd33b04f 100644 --- a/packages/object-storage/src/index.ts +++ b/packages/object-storage/src/index.ts @@ -1,7 +1,11 @@ +export type { Bucket, ObjectBody, ObjectHead, ObjectRead, UnfinishedPut } from './bucket/bucket.ts'; export { storageClientOf, type StorageCredentials, type StorageEndpoint, type StorageWaits, } from './client/storage-client.ts'; -export { objectContentOn, objectStoreReached, ObjectStoreUnreachable } from './content/object-content.ts'; +export { objectContentOn } from './content/object-content.ts'; +export type { S3Bucket } from './s3/bucket-requests.ts'; +export { objectStoreReached, ObjectStoreUnreachable } from './s3/object-store-reached.ts'; +export { bucketOnS3 } from './s3/s3-bucket.ts'; diff --git a/packages/object-storage/src/content/bucket-requests.ts b/packages/object-storage/src/s3/bucket-requests.ts similarity index 75% rename from packages/object-storage/src/content/bucket-requests.ts rename to packages/object-storage/src/s3/bucket-requests.ts index bb5099a0e..c548d8350 100644 --- a/packages/object-storage/src/content/bucket-requests.ts +++ b/packages/object-storage/src/s3/bucket-requests.ts @@ -11,23 +11,25 @@ import { S3ServiceException, type S3Client, } from '@aws-sdk/client-s3'; -import { verifiedStream, type StreamedContent } from '@on.auto/operations'; -export interface Bucket { +import type { ObjectHead, ObjectRead } from '../bucket/bucket.ts'; + +export interface S3Bucket { readonly client: () => S3Client; readonly name: string; } -export interface Held { - readonly bytes: number; - readonly mediaType: string; -} - export interface OpenUpload { readonly key: string; readonly uploadId: string; } +export interface WrittenObject { + readonly data: Readonly; + readonly mediaType?: string | undefined; + readonly ifAbsent?: boolean | undefined; +} + interface Listed { readonly Key?: string | undefined; readonly UploadId?: string | undefined; @@ -88,6 +90,15 @@ async function writtenIfFirst(write: () => Promise, attempt = 1): Promi } } +async function writtenAlways(write: () => Promise): Promise { + await write(); + return true; +} + +function writtenAsAsked(ifAbsent: boolean | undefined, write: () => Promise): Promise { + return ifAbsent === true ? writtenIfFirst(write) : writtenAlways(write); +} + function answeredWithout(key: string, missing: string): Error { return new Error(`The object store answered for ${key} without its ${missing}`); } @@ -134,22 +145,18 @@ function isOpenUpload(upload: ListedUpload): upload is OpenUpload { return [upload.key, upload.uploadId].every((part) => part !== undefined); } -export async function bucketReached({ client, name }: Bucket): Promise { +export async function bucketReached({ client, name }: S3Bucket): Promise { await client().send(new HeadBucketCommand({ Bucket: name })); } -export async function heldAt({ client, name }: Bucket, key: string): Promise { +export async function heldAt({ client, name }: S3Bucket, key: string): Promise { const head = await unlessNotFound(client().send(new HeadObjectCommand({ Bucket: name, Key: key }))); return head === undefined ? undefined : { bytes: sizeOf(key, head.ContentLength), mediaType: head.ContentType ?? octets }; } -export async function readAt( - { client, name }: Bucket, - key: string, - { sha256, waitMs }: { readonly sha256: string; readonly waitMs: number }, -): Promise { +export async function readAt({ client, name }: S3Bucket, key: string, waitMs: number): Promise { const object = await unlessNotFound(client().send(new GetObjectCommand({ Bucket: name, Key: key }))); if (object === undefined) { return undefined; @@ -159,42 +166,43 @@ export async function readAt( } const bytes = sizeOf(key, object.ContentLength); return { - stream: verifiedStream(sha256, bytes, piecesWithin(object.Body.transformToWebStream(), waitMs, key)), + stream: piecesWithin(object.Body.transformToWebStream(), waitMs, key), bytes, mediaType: object.ContentType ?? octets, }; } -export async function marked({ client, name }: Bucket, key: string): Promise { - await client().send(new PutObjectCommand({ Bucket: name, Key: key, Body: new Uint8Array() })); -} - -export function writtenFirst( - { client, name }: Bucket, +export function written( + { client, name }: S3Bucket, key: string, - { body, mediaType }: { readonly body: Readonly; readonly mediaType: string }, + { data, mediaType, ifAbsent }: WrittenObject, ): Promise { const put = new PutObjectCommand({ Bucket: name, Key: key, - Body: body, - ContentType: mediaType, - IfNoneMatch: ifNoneMatch, + Body: data, + ...(mediaType === undefined ? {} : { ContentType: mediaType }), + ...(ifAbsent === true ? { IfNoneMatch: ifNoneMatch } : {}), }); - return writtenIfFirst(() => client().send(put)); + return writtenAsAsked(ifAbsent, () => client().send(put)); } -export function copiedFirst({ client, name }: Bucket, from: string, to: string): Promise { +export function copied( + { client, name }: S3Bucket, + from: string, + to: string, + ifAbsent: boolean | undefined, +): Promise { const copy = new CopyObjectCommand({ Bucket: name, Key: to, CopySource: copySourceOf(name, from), - IfNoneMatch: ifNoneMatch, + ...(ifAbsent === true ? { IfNoneMatch: ifNoneMatch } : {}), }); - return writtenIfFirst(() => client().send(copy)); + return writtenAsAsked(ifAbsent, () => client().send(copy)); } -export async function removed(bucket: Bucket, keys: readonly string[]): Promise { +export async function removed(bucket: S3Bucket, keys: readonly string[]): Promise { if (keys.length === 0) { return; } @@ -210,7 +218,7 @@ export async function removed(bucket: Bucket, keys: readonly string[]): Promise< await removed(bucket, keys.slice(mostKeysInOneDelete)); } -export async function* keysUnder(bucket: Bucket, prefix: string, token?: string): AsyncGenerator { +export async function* keysUnder(bucket: S3Bucket, prefix: string, token?: string): AsyncGenerator { const page = await bucket .client() .send(new ListObjectsV2Command({ Bucket: bucket.name, Prefix: prefix, ContinuationToken: token })); @@ -221,7 +229,7 @@ export async function* keysUnder(bucket: Bucket, prefix: string, token?: string) } export async function* uploadsUnder( - bucket: Bucket, + bucket: S3Bucket, prefix: string, markers?: UploadMarkers, ): AsyncGenerator { @@ -235,6 +243,6 @@ export async function* uploadsUnder( } } -export async function uploadAbandoned({ client, name }: Bucket, { key, uploadId }: OpenUpload): Promise { +export async function uploadAbandoned({ client, name }: S3Bucket, { key, uploadId }: OpenUpload): Promise { await client().send(new AbortMultipartUploadCommand({ Bucket: name, Key: key, UploadId: uploadId })); } diff --git a/packages/object-storage/src/content/object-store-reached.test.ts b/packages/object-storage/src/s3/object-store-reached.test.ts similarity index 99% rename from packages/object-storage/src/content/object-store-reached.test.ts rename to packages/object-storage/src/s3/object-store-reached.test.ts index 7a476a7cc..bec047822 100644 --- a/packages/object-storage/src/content/object-store-reached.test.ts +++ b/packages/object-storage/src/s3/object-store-reached.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { fakeBucket } from '../testing/fake-bucket.ts'; import { bucketReady, testObjectStoreOf } from '../testing/test-object-store.ts'; -import { objectStoreReached, ObjectStoreUnreachable } from './object-content.ts'; +import { objectStoreReached, ObjectStoreUnreachable } from './object-store-reached.ts'; const location = { bucket: 'content', prefix: 'playbooks' }; diff --git a/packages/object-storage/src/s3/object-store-reached.ts b/packages/object-storage/src/s3/object-store-reached.ts new file mode 100644 index 000000000..0dfbbed9a --- /dev/null +++ b/packages/object-storage/src/s3/object-store-reached.ts @@ -0,0 +1,27 @@ +import { S3ServiceException, type S3Client } from '@aws-sdk/client-s3'; +import { Data, Effect } from 'effect'; + +import type { ContentLocation } from '../location/content-location.ts'; +import { bucketReached } from './bucket-requests.ts'; + +export class ObjectStoreUnreachable extends Data.TaggedError('object_store_unreachable')<{ + readonly bucket: string; + readonly answer: string; +}> {} + +function answerOf(failure: unknown): string { + if (failure instanceof S3ServiceException) { + return `${failure.name} (${String(failure.$metadata.httpStatusCode)})`; + } + return String(failure); +} + +export function objectStoreReached( + client: () => S3Client, + { bucket }: ContentLocation, +): Effect.Effect { + return Effect.tryPromise({ + try: () => bucketReached({ client, name: bucket }), + catch: (failure) => new ObjectStoreUnreachable({ bucket, answer: answerOf(failure) }), + }); +} diff --git a/packages/object-storage/src/s3/s3-bucket.ts b/packages/object-storage/src/s3/s3-bucket.ts new file mode 100644 index 000000000..0af23c047 --- /dev/null +++ b/packages/object-storage/src/s3/s3-bucket.ts @@ -0,0 +1,66 @@ +import { Upload } from '@aws-sdk/lib-storage'; + +import type { Bucket, ObjectBody, UnfinishedPut } from '../bucket/bucket.ts'; +import { storageWaits, type StorageWaits } from '../client/storage-client.ts'; +import { + copied, + heldAt, + keysUnder, + readAt, + removed, + uploadAbandoned, + uploadsUnder, + written, + type S3Bucket, +} from './bucket-requests.ts'; + +export const uploadPartBytes = 5 * 1_048_576; + +const uploadPartsAtOnce = 2; + +interface StreamedObject { + readonly stream: ReadableStream; + readonly mediaType: string; +} + +async function uploaded( + { client, name }: S3Bucket, + key: string, + { stream, mediaType }: StreamedObject, +): Promise { + const upload = new Upload({ + client: client(), + params: { Bucket: name, Key: key, Body: stream, ContentType: mediaType }, + partSize: uploadPartBytes, + queueSize: uploadPartsAtOnce, + }); + await upload.done(); + return true; +} + +async function* unfinishedUnder(bucket: S3Bucket, prefix: string): AsyncGenerator { + for await (const open of uploadsUnder(bucket, prefix)) { + yield { key: open.key, abandon: () => uploadAbandoned(bucket, open) }; + } +} + +function putIn(bucket: S3Bucket, key: string, body: ObjectBody): Promise { + if ('stream' in body) { + return uploaded(bucket, key, body); + } + if ('copyOf' in body) { + return copied(bucket, body.copyOf, key, body.ifAbsent); + } + return written(bucket, key, body); +} + +export function bucketOnS3(bucket: S3Bucket, { requestMs }: StorageWaits = storageWaits): Bucket { + return { + put: (key, body) => putIn(bucket, key, body), + get: (key) => readAt(bucket, key, requestMs), + head: (key) => heldAt(bucket, key), + delete: (keys) => removed(bucket, keys), + list: (prefix) => keysUnder(bucket, prefix), + unfinished: (prefix) => unfinishedUnder(bucket, prefix), + }; +} diff --git a/packages/object-storage/src/testing/fake-bucket.ts b/packages/object-storage/src/testing/fake-bucket.ts index 9f5b2a707..1606da67b 100644 --- a/packages/object-storage/src/testing/fake-bucket.ts +++ b/packages/object-storage/src/testing/fake-bucket.ts @@ -2,9 +2,10 @@ import type { S3Client, ServiceOutputTypes } from '@aws-sdk/client-s3'; import type { ContentStore } from '@on.auto/operations'; import { Schema } from 'effect'; -import { storageClientOf } from '../client/storage-client.ts'; +import { storageClientOf, type StorageWaits } from '../client/storage-client.ts'; import { objectContentOn } from '../content/object-content.ts'; import type { ContentLocation } from '../location/content-location.ts'; +import { bucketOnS3 } from '../s3/s3-bucket.ts'; import { answersOf, found, shelvesOf } from './fake-shelves.ts'; type Interceptor = (asked: () => Promise) => Promise; @@ -75,8 +76,20 @@ export function fakeBucket({ }; } -export function fakeContentStore(location: ContentLocation, options: FakeBucketOptions = {}): FakeContentStore { +export interface FakeContentOptions extends FakeBucketOptions { + readonly waits?: StorageWaits; +} + +export function fakeContentStore( + location: ContentLocation, + { waits, ...options }: FakeContentOptions = {}, +): FakeContentStore { const bucket = fakeBucket({ name: location.bucket, ...options }); const client = bucket.client(); - return { bucket, client: () => client, ...objectContentOn(() => client, location) }; + const answering = () => client; + return { + bucket, + client: answering, + ...objectContentOn(bucketOnS3({ client: answering, name: location.bucket }, waits), location), + }; } diff --git a/packages/object-storage/src/testing/object-content-behaviour.ts b/packages/object-storage/src/testing/object-content-behaviour.ts index cb74666c5..c95e43fe1 100644 --- a/packages/object-storage/src/testing/object-content-behaviour.ts +++ b/packages/object-storage/src/testing/object-content-behaviour.ts @@ -11,9 +11,10 @@ import { } from '@on.auto/operations/testing'; import { describe, expect, it } from 'vitest'; -import { copiedFirst, heldAt } from '../content/bucket-requests.ts'; -import { objectContentOn, objectStoreReached, ObjectStoreUnreachable } from '../content/object-content.ts'; +import { objectContentOn } from '../content/object-content.ts'; import { objectKeysOf } from '../location/object-keys.ts'; +import { objectStoreReached, ObjectStoreUnreachable } from '../s3/object-store-reached.ts'; +import { bucketOnS3 } from '../s3/s3-bucket.ts'; import type { ObjectStoreEntry, ObjectStoreUnderTest } from './object-store-entry.ts'; import { keysIn, openUploadsIn } from './test-object-store.ts'; @@ -22,14 +23,15 @@ const longAgo = '00000000-0000-7000-8000-000000000001'; function keptOnObjects(entry: ObjectStoreEntry): void { it('keeps a content as one object under its playbook and digest, with its media type and size, and stages it under its run until the run is unstaged', async () => { const store = await entry.aStore(); - const { content } = objectContentOn(store.bucket.client, store.location); + const bucket = bucketOnS3(store.bucket); + const { content } = objectContentOn(bucket, store.location); const keys = objectKeysOf(store.location); const { sha256, content: generatedContent } = await generated(2_500_000); await resultOf(content.put(alpha, { ...generatedContent(), mediaType: json })); await resultOf(content.stage(alpha, 'run/1', generatedContent())); const kept = await keysIn(store.bucket, store.location.prefix); - const held = await heldAt(store.bucket, keys.kept(alpha, sha256)); + const held = await bucket.head(keys.kept(alpha, sha256)); await resultOf(content.unstage(alpha, 'run/1')); expect(kept).toEqual( @@ -46,11 +48,12 @@ function keptOnObjects(entry: ObjectStoreEntry): void { function misfiledRead(entry: ObjectStoreEntry): void { it('fails a read of an object whose bytes are not those its digest names, before it hands over the last of them', async () => { const store = await entry.aStore(); - const { content } = objectContentOn(store.bucket.client, store.location); + const bucket = bucketOnS3(store.bucket); + const { content } = objectContentOn(bucket, store.location); const keys = objectKeysOf(store.location); const { sha256 } = await resultOf(content.put(alpha, (await generated(2_500_000)).content())); const misfiled = sha256Of('another content'); - await copiedFirst(store.bucket, keys.kept(alpha, sha256), keys.kept(alpha, misfiled)); + await bucket.put(keys.kept(alpha, misfiled), { copyOf: keys.kept(alpha, sha256) }); const delivered = await deliveredBeforeFailing((await present(content.get(alpha, misfiled))).stream); @@ -75,7 +78,7 @@ async function uploadsBegun(store: ObjectStoreUnderTest, fresh: string): Promise function uploadsSwept(entry: ObjectStoreEntry): void { it('sweeps the uploads and the open multipart uploads begun before the time it is given, and keeps those begun after it', async () => { const store = await entry.aStore(); - const { sweepContent } = objectContentOn(store.bucket.client, store.location); + const { sweepContent } = objectContentOn(bucketOnS3(store.bucket), store.location); const keys = objectKeysOf(store.location); const fresh = randomUUIDv7(); await uploadsBegun(store, fresh); diff --git a/packages/object-storage/src/testing/object-store-entry.ts b/packages/object-storage/src/testing/object-store-entry.ts index 079b557de..c7cc199d6 100644 --- a/packages/object-storage/src/testing/object-store-entry.ts +++ b/packages/object-storage/src/testing/object-store-entry.ts @@ -2,10 +2,11 @@ import type { S3Client } from '@aws-sdk/client-s3'; import type { ContentEntry } from '@on.auto/operations/testing'; import { onTestFinished } from 'vitest'; -import type { Bucket } from '../content/bucket-requests.ts'; import { objectContentOn } from '../content/object-content.ts'; import type { ContentLocation } from '../location/content-location.ts'; import { objectKeysOf } from '../location/object-keys.ts'; +import type { S3Bucket } from '../s3/bucket-requests.ts'; +import { bucketOnS3 } from '../s3/s3-bucket.ts'; import { emptied, keysIn, openUploadsIn } from './test-object-store.ts'; export interface StoreClients { @@ -15,7 +16,7 @@ export interface StoreClients { } export interface ObjectStoreUnderTest { - readonly bucket: Bucket; + readonly bucket: S3Bucket; readonly location: ContentLocation; readonly reading: () => S3Client; } @@ -47,8 +48,9 @@ export function contentEntryOf(entry: ObjectStoreEntry): ContentEntry { const unnamed = async () => (await keysIn(store.bucket, uploads)).length + (await openUploadsIn(store.bucket, uploads)).length; return { - content: objectContentOn(store.bucket.client, store.location).content, - elsewhere: objectContentOn(store.reading, store.location).content, + content: objectContentOn(bucketOnS3(store.bucket), store.location).content, + elsewhere: objectContentOn(bucketOnS3({ client: store.reading, name: store.bucket.name }), store.location) + .content, unnamedLeft: unnamed, }; }, diff --git a/packages/object-storage/src/testing/test-object-store.ts b/packages/object-storage/src/testing/test-object-store.ts index 0158c390d..334d3cbd2 100644 --- a/packages/object-storage/src/testing/test-object-store.ts +++ b/packages/object-storage/src/testing/test-object-store.ts @@ -7,11 +7,11 @@ import { removed, uploadAbandoned, uploadsUnder, - type Bucket, -} from '../content/bucket-requests.ts'; + type S3Bucket, +} from '../s3/bucket-requests.ts'; export interface TestObjectStore { - readonly bucket: Bucket; + readonly bucket: S3Bucket; readonly endpoint: string; readonly accessKeyId: string; readonly secretAccessKey: string; @@ -35,20 +35,20 @@ export function testObjectStoreOf(url: string): TestObjectStore { return { bucket: { client: () => client, name: pathname.slice(1) }, endpoint: origin, accessKeyId, secretAccessKey }; } -export async function bucketReady(bucket: Bucket): Promise { +export async function bucketReady(bucket: S3Bucket): Promise { const created = () => bucket.client().send(new CreateBucketCommand({ Bucket: bucket.name })); await bucketReached(bucket).catch(() => created().catch(() => bucketReached(bucket))); } -export function keysIn(bucket: Bucket, prefix: string): Promise { +export function keysIn(bucket: S3Bucket, prefix: string): Promise { return listed(keysUnder(bucket, prefix)); } -export async function openUploadsIn(bucket: Bucket, prefix: string): Promise { +export async function openUploadsIn(bucket: S3Bucket, prefix: string): Promise { return (await listed(uploadsUnder(bucket, prefix))).map(({ key }) => key); } -export async function emptied(bucket: Bucket, prefix: string): Promise { +export async function emptied(bucket: S3Bucket, prefix: string): Promise { const open = await listed(uploadsUnder(bucket, prefix)); await Promise.all(open.map((upload) => uploadAbandoned(bucket, upload))); await removed(bucket, await keysIn(bucket, prefix)); diff --git a/packages/server/src/composition/application-layer.ts b/packages/server/src/composition/application-layer.ts index 2928dadf5..975f204e5 100644 --- a/packages/server/src/composition/application-layer.ts +++ b/packages/server/src/composition/application-layer.ts @@ -1,11 +1,13 @@ -import { IncidentReporter, type DispatcherServices, type Ledger } from '@on.auto/operations'; +import type { LedgerServices } from '@on.auto/ledger'; +import { IncidentReporter } from '@on.auto/operations'; import { ledgerPlaybookRegistry } from '@on.auto/playbooks'; import { Layer } from 'effect'; import { logIncident } from '../logging/logging.ts'; +import type { ServerServices } from '../server-options/composed-options.ts'; const loggingIncidentReporter = Layer.succeed(IncidentReporter, IncidentReporter.of({ report: logIncident })); -export function applicationLayer(ledger: Layer.Layer): Layer.Layer { +export function applicationLayer(ledger: Layer.Layer): Layer.Layer { return Layer.mergeAll(ledger, ledgerPlaybookRegistry.pipe(Layer.provide(ledger)), loggingIncidentReporter); } diff --git a/packages/server/src/composition/composition-root.test.ts b/packages/server/src/composition/composition-root.test.ts index 30e0e58ef..ecdb3847e 100644 --- a/packages/server/src/composition/composition-root.test.ts +++ b/packages/server/src/composition/composition-root.test.ts @@ -27,6 +27,8 @@ describe('the services the playbook operations run on', () => { await runtime.dispose(); expect(keys.filter((key) => !key.startsWith('effect/')).toSorted()).toEqual([ + '@on.auto/ledger/LedgerChores', + '@on.auto/ledger/LedgerConnection', '@on.auto/operations/IncidentReporter', '@on.auto/operations/Ledger', '@on.auto/operations/PlaybookRegistry', diff --git a/packages/server/src/composition/composition-root.ts b/packages/server/src/composition/composition-root.ts index 70bd045ef..d6d64aa80 100644 --- a/packages/server/src/composition/composition-root.ts +++ b/packages/server/src/composition/composition-root.ts @@ -1,6 +1,6 @@ import { capabilityRegistryHeld } from '../capability-registry/capability-registry.ts'; import { storesAndConnectionsOf } from '../connections/served-connections.ts'; -import { ledgerDatabaseOf, withARefusedLayoutExplained } from '../database/ledger-database.ts'; +import { ledgerConnectionIn, ledgerDatabaseOf, withARefusedLayoutExplained } from '../database/ledger-database.ts'; import { serverOptionsComposing, type ComposedServerOptions } from '../server-options/composed-options.ts'; import { serveWorkflows, type WorkflowParts } from '../workflows/workflows.ts'; import { applicationLayer } from './application-layer.ts'; @@ -36,7 +36,7 @@ export function compositionRootWith( consent, connections: source, }); - const functions = await functionsServedBy(runtime, settings, database, { + const functions = await functionsServedBy(runtime, settings, await ledgerConnectionIn(runtime), { ...wiring, registry, connections: source, diff --git a/packages/server/src/composition/content-store.ts b/packages/server/src/composition/content-store.ts index 0c61353f8..8c07cffe7 100644 --- a/packages/server/src/composition/content-store.ts +++ b/packages/server/src/composition/content-store.ts @@ -29,13 +29,13 @@ export function contentStoreOf({ `CONTENT_STORE_URL names the bucket ${location.bucket} on ${hostOf(endpoint)}, which could not be reached: ${answer}`, ); return Effect.gen(function* () { - const [{ objectContentOn, objectStoreReached, storageClientOf }, credentials] = yield* Effect.promise(() => - Promise.all([import('@on.auto/object-storage'), awsCredentialChainOf(proxy)]), + const [{ bucketOnS3, objectContentOn, objectStoreReached, storageClientOf }, credentials] = yield* Effect.promise( + () => Promise.all([import('@on.auto/object-storage'), awsCredentialChainOf(proxy)]), ); const client = storageClientOf({ endpoint, region, proxy }, credentials); client.middlewareStack.add(commandSpansIn(location.bucket), commandSpanStep); const composed: ComposedContentStore = { - store: objectContentOn(() => client, location), + store: objectContentOn(bucketOnS3({ client: () => client, name: location.bucket }), location), reached: objectStoreReached(() => client, location).pipe( Effect.catchTag('object_store_unreachable', ({ answer }: { readonly answer: string }) => Effect.die(unreachable(answer)), diff --git a/packages/server/src/composition/ledger-store.ts b/packages/server/src/composition/ledger-store.ts index c8c4d533c..a0c08ad4b 100644 --- a/packages/server/src/composition/ledger-store.ts +++ b/packages/server/src/composition/ledger-store.ts @@ -1,7 +1,7 @@ import { runOutcomeMapping } from '@on.auto/definitions'; import { conversations, openRequests } from '@on.auto/interaction'; -import type { AppendSignal } from '@on.auto/ledger'; -import type { ContentStore, Ledger } from '@on.auto/operations'; +import type { AppendSignal, LedgerServices } from '@on.auto/ledger'; +import type { ContentStore } from '@on.auto/operations'; import { predictionBuilds, predictionModels } from '@on.auto/prediction'; import { Effect, Layer } from 'effect'; @@ -14,7 +14,7 @@ export function ledgerLayerOf( database: LedgerDatabase, appends?: AppendSignal, content?: ContentStore, -): Layer.Layer { +): Layer.Layer { return database.ledgerLayer({ runOutcomes: runOutcomeMapping, projections, @@ -27,7 +27,7 @@ export function ledgerLayerFor( settings: ContentStoreSettings, database: LedgerDatabase, appends?: AppendSignal, -): Layer.Layer { +): Layer.Layer { const composing = contentStoreOf(settings); return composing === undefined ? ledgerLayerOf(database, appends) diff --git a/packages/server/src/composition/served-functions.ts b/packages/server/src/composition/served-functions.ts index afe683423..2915a10c4 100644 --- a/packages/server/src/composition/served-functions.ts +++ b/packages/server/src/composition/served-functions.ts @@ -14,7 +14,7 @@ import { } from './served-prediction-functions.ts'; import { toolUsersServedBy, type ToolUsersWiring } from './served-tools.ts'; -type ProjectedDatabase = Parameters[1]['database']; +type ProjectedDatabase = Parameters[0]['database']; export interface ServedFunctions { readonly parts: Omit; @@ -34,7 +34,7 @@ export async function functionsServedBy( ): Promise { const { reasoning, interaction, toolOperations } = await toolUsersServedBy(runtime, settings, tools); const computation = await computationServedBy(settings.computation, programPoolOf, shippedFiles); - const projections = await wiring.served(runtime, { recall: settings.recall, database }, computation.pool, [ + const projections = await wiring.served({ recall: settings.recall, database }, computation.pool, [ datasetsFolderOf(computation.pool), ]); const prediction = await predictionFunctionsServedBy(runtime, settings, computation.pool, projections.store); diff --git a/packages/server/src/composition/served-projections.ts b/packages/server/src/composition/served-projections.ts index f9ec2e0d1..5669bfbaa 100644 --- a/packages/server/src/composition/served-projections.ts +++ b/packages/server/src/composition/served-projections.ts @@ -1,7 +1,5 @@ -import type { AppRuntime } from '@on.auto/api'; import type { Capability } from '@on.auto/definitions'; -import { appendSignal, type AppendSignal } from '@on.auto/ledger'; -import type { DispatcherServices } from '@on.auto/operations'; +import { appendSignal, type AppendSignal, type ConnectionPorts } from '@on.auto/ledger'; import { makeRecallFunctionAdapter, recallBounds, recallDefinitionType, recallFolding } from '@on.auto/recall'; import type { ProgramPool } from '@on.auto/workflow-engine/dsl'; import { @@ -9,14 +7,12 @@ import { someFolder, viewsFolder, viewsPortOn, - type DatabaseSettings, type ProjectorSettings, type SomeFolder, type WorkflowStore, } from '@on.auto/workflow-host'; import type { Settings } from '../settings/settings.ts'; -import { hostReports } from '../workflows/host-reports.ts'; interface ServedProjections { readonly recall: Capability; @@ -25,24 +21,19 @@ interface ServedProjections { } interface ProjectedOn extends Pick { - readonly database: DatabaseSettings; + readonly database: ConnectionPorts; } export interface ProjectionWiring { readonly appends: AppendSignal; - readonly served: ( - runtime: AppRuntime, - on: ProjectedOn, - pool: ProgramPool, - besides: readonly SomeFolder[], - ) => Promise; + readonly served: (on: ProjectedOn, pool: ProgramPool, besides: readonly SomeFolder[]) => Promise; } export function projectionWiring(): ProjectionWiring { const appends = appendSignal(); return { appends, - served: async (runtime, { recall, database }, pool, besides) => { + served: async ({ recall, database }, pool, besides) => { const views = viewsFolder({ definitionType: recallDefinitionType, pool, @@ -51,7 +42,7 @@ export function projectionWiring(): ProjectionWiring { rebuildsAtOnce: recall.rebuildsAtOnce, }); const folders = [someFolder(views), ...besides]; - const store = await openWorkflowStore(database, hostReports(runtime).lostConnection, folders); + const store = await openWorkflowStore(database, folders); return { recall: makeRecallFunctionAdapter({ pool, diff --git a/packages/server/src/database/ledger-database.test.ts b/packages/server/src/database/ledger-database.test.ts index d6f3bab2d..76ed94234 100644 --- a/packages/server/src/database/ledger-database.test.ts +++ b/packages/server/src/database/ledger-database.test.ts @@ -1,32 +1,41 @@ -import { LedgerLayoutRefused } from '@on.auto/ledger'; +import { LedgerConnection, LedgerLayoutRefused, statement } from '@on.auto/ledger'; +import { unreachableDatabase } from '@on.auto/ledger/testing'; import { Ledger } from '@on.auto/operations'; -import { Cause, Effect, Exit, Layer } from 'effect'; -import { describe, expect, it } from 'vitest'; +import { Cause, Effect, Exit, Layer, ManagedRuntime } from 'effect'; +import { describe, expect, it, onTestFinished } from 'vitest'; import { secretsKey } from '../testing/databases/secrets-key.ts'; +import { temporaryLedger } from '../testing/records/temporary-ledger.ts'; import { readSettingsOf } from '../testing/settings/shipped-settings.ts'; import { ledgerDatabaseOf, withARefusedLayoutExplained } from './ledger-database.ts'; import { LedgerOfAnotherVersion } from './ledger-of-another-version.ts'; -const somePools: unknown = expect.any(Function); +async function builtFrom(ledgerLayer: Layer.Layer): Promise { + const exit = await Effect.runPromiseExit(Effect.scoped(Layer.build(ledgerLayer))); + return Exit.isFailure(exit) ? Cause.squash(exit.cause) : 'built'; +} describe('the database of the ledger and the workflow host', () => { - it('is the file of the ledger when the ledger is kept in SQLite', async () => { - expect(await ledgerDatabaseOf(readSettingsOf({ LEDGER_FILE: '/data/ledger.db' }))).toMatchObject({ - store: 'sqlite', - file: '/data/ledger.db', - }); + it('is the file of the ledger when the ledger is kept in SQLite, where its connection runs the statements of the host', async () => { + const ledger = temporaryLedger(); + onTestFinished(ledger.remove); + const database = await ledgerDatabaseOf(readSettingsOf({ LEDGER_FILE: ledger.fileName })); + const runtime = ManagedRuntime.make(database.ledgerLayer({})); + onTestFinished(() => runtime.dispose()); + + const connection = await runtime.runPromise(Effect.service(LedgerConnection)); + const streams = await connection.read( + statement`SELECT COUNT(*) AS kept FROM sqlite_master WHERE name = 'emt_streams'`, + ); + + expect([connection.dialect, streams]).toEqual(['sqlite', [{ kept: 1 }]]); }); it('is reached through the pools the server composes when the ledger is kept in PostgreSQL', async () => { - expect( - await ledgerDatabaseOf( - readSettingsOf({ - DATABASE_URL: 'postgresql://playbooks:secret@db.example.com/playbooks', - SECRETS_KEY: secretsKey, - }), - ), - ).toMatchObject({ store: 'postgresql', pools: somePools }); + const { url, port } = await unreachableDatabase(); + const database = await ledgerDatabaseOf(readSettingsOf({ DATABASE_URL: url, SECRETS_KEY: secretsKey })); + + expect(String(await builtFrom(database.ledgerLayer({})))).toContain(`ECONNREFUSED 127.0.0.1:${port}`); }); }); @@ -36,11 +45,6 @@ const refusedLayout = new LedgerLayoutRefused({ message: "The ledger's database is in layout 2 of the ledger, where this server keeps layout 1", }); -async function builtFrom(ledgerLayer: Layer.Layer): Promise { - const exit = await Effect.runPromiseExit(Effect.scoped(Layer.build(ledgerLayer))); - return Exit.isFailure(exit) ? Cause.squash(exit.cause) : 'built'; -} - describe('a ledger that refuses the layout of its database as it opens', () => { it('stops the start with words that say where the ledger is kept, from the settings', async () => { const refused = await builtFrom( diff --git a/packages/server/src/database/ledger-database.ts b/packages/server/src/database/ledger-database.ts index fb9175dc8..94cff2429 100644 --- a/packages/server/src/database/ledger-database.ts +++ b/packages/server/src/database/ledger-database.ts @@ -1,10 +1,20 @@ -import { LedgerLayoutRefused, type AppendObserver, type AppendSignal } from '@on.auto/ledger'; -import { Ledger, type ContentStore, type KeyedProjection, type RunOutcomeMapping } from '@on.auto/operations'; +import type { AppRuntime } from '@on.auto/api'; +import { + LedgerChores, + LedgerConnection, + LedgerLayoutRefused, + type AppendObserver, + type AppendSignal, + type ConnectionPorts, + type LedgerServices, +} from '@on.auto/ledger'; +import type { ContentStore, KeyedProjection, RunOutcomeMapping } from '@on.auto/operations'; import type { SecretRows } from '@on.auto/secrets'; -import type { DatabaseSettings } from '@on.auto/workflow-host'; -import { Cause, Effect, Layer } from 'effect'; +import { choreWork, type DueWork } from '@on.auto/workflow-host'; +import { Cause, Effect, Layer, type Context } from 'effect'; import type { Settings } from '../settings/settings.ts'; +import { inRuntime } from '../workflows/in-runtime.ts'; import { ledgerOfAnotherVersion } from './ledger-of-another-version.ts'; interface KeptByTheLedger { @@ -14,10 +24,10 @@ interface KeptByTheLedger { readonly content?: ContentStore; } -export type LedgerDatabase = DatabaseSettings & { - readonly ledgerLayer: (kept: KeptByTheLedger) => Layer.Layer; +export interface LedgerDatabase { + readonly ledgerLayer: (kept: KeptByTheLedger) => Layer.Layer; readonly secretRows: Effect.Effect; -}; +} export async function ledgerDatabaseOf( { ledger, proxy }: Pick, @@ -31,17 +41,25 @@ export async function ledgerDatabaseOf( return postgresqlLedgerDatabaseOf(ledger, proxy, observer); } -export function withARefusedLayoutExplained( - ledgerLayer: Layer.Layer, +export function withARefusedLayoutExplained( + ledgerLayer: Layer.Layer, settings: Pick, -): Layer.Layer { +): Layer.Layer { return Layer.catchCause(ledgerLayer, (cause) => { const failure = Cause.squash(cause); - return Layer.effect( - Ledger, + const refused: Effect.Effect> = failure instanceof LedgerLayoutRefused ? Effect.die(ledgerOfAnotherVersion(failure, settings)) - : Effect.failCause(cause), - ); + : Effect.failCause(cause); + return Layer.effectContext(refused); }); } + +export function ledgerConnectionIn(runtime: AppRuntime): Promise { + return Effect.runPromise(inRuntime(runtime, Effect.service(LedgerConnection))); +} + +export async function orphansSweptIn(runtime: AppRuntime): Promise { + const { sweepOrphans } = await Effect.runPromise(inRuntime(runtime, Effect.service(LedgerChores))); + return choreWork({ name: 'orphaned content', perform: sweepOrphans }); +} diff --git a/packages/server/src/database/postgresql-ledger-database.ts b/packages/server/src/database/postgresql-ledger-database.ts index 90c433ebc..d59b40eee 100644 --- a/packages/server/src/database/postgresql-ledger-database.ts +++ b/packages/server/src/database/postgresql-ledger-database.ts @@ -8,9 +8,6 @@ import type { LedgerDatabase } from './ledger-database.ts'; export function postgresqlLedgerDatabase(pools: PostgreSQLPools, observer?: AppendObserver): LedgerDatabase { return { - store: 'postgresql', - pools, - observer, ledgerLayer: (kept) => postgresqlLedgerLayer({ ...kept, pools, observer }), secretRows: postgresqlSecretRows({ pools }), }; diff --git a/packages/server/src/database/sqlite-ledger-database.ts b/packages/server/src/database/sqlite-ledger-database.ts index 902c13a35..5ebecd38a 100644 --- a/packages/server/src/database/sqlite-ledger-database.ts +++ b/packages/server/src/database/sqlite-ledger-database.ts @@ -5,9 +5,6 @@ import type { LedgerDatabase } from './ledger-database.ts'; export function sqliteLedgerDatabase(file: string, observer?: AppendObserver): LedgerDatabase { return { - store: 'sqlite', - file, - observer, ledgerLayer: (kept) => ledgerLayer({ ...kept, fileName: file, observer }), secretRows: sqliteSecretRows({ fileName: file }), }; diff --git a/packages/server/src/logging/logging.test.ts b/packages/server/src/logging/logging.test.ts index 38d34700d..a3b8a93de 100644 --- a/packages/server/src/logging/logging.test.ts +++ b/packages/server/src/logging/logging.test.ts @@ -9,7 +9,6 @@ import { linesLoggedBy } from '../testing/records/logged-lines.ts'; import { logAccessMode, logIncident, - logLostWorkflowConnection, logMcpError, logReasoning, logUnsettled, @@ -152,15 +151,6 @@ describe('the workflow warnings', () => { expect(line).toContain('"error":"Error: the database is gone xxx'); expect(line).not.toContain('x'.repeat(2000)); }); - - it('warn that a connection of the workflows to PostgreSQL was lost, with the message of its error', async () => { - const [line] = await linesLoggedBy(logLostWorkflowConnection(new Error('Connection terminated unexpectedly'))); - - expect(line).toContain( - '"message":"A connection of the workflows to their PostgreSQL database was lost; they open another when they need one","level":"WARN"', - ); - expect(line).toContain('"annotations":{"error":"Connection terminated unexpectedly"}'); - }); }); describe('logMcpError', () => { diff --git a/packages/server/src/logging/logging.ts b/packages/server/src/logging/logging.ts index 6d20fe38d..407f7aff9 100644 --- a/packages/server/src/logging/logging.ts +++ b/packages/server/src/logging/logging.ts @@ -222,12 +222,6 @@ export function logWorkflowTrouble(what: string, cause: Cause.Cause): E ); } -export function logLostWorkflowConnection(error: Readonly): Effect.Effect { - return Effect.logWarning( - 'A connection of the workflows to their PostgreSQL database was lost; they open another when they need one', - ).pipe(Effect.annotateLogs({ error: error.message })); -} - function destinationOf(target: SignalExport | undefined): string { return target === undefined ? 'off' : `${new URL(target.url).origin} over ${target.protocol}`; } diff --git a/packages/server/src/server-options/composed-options.ts b/packages/server/src/server-options/composed-options.ts index 0c44112c3..4cef04cda 100644 --- a/packages/server/src/server-options/composed-options.ts +++ b/packages/server/src/server-options/composed-options.ts @@ -1,3 +1,4 @@ +import type { LedgerChores, LedgerConnection } from '@on.auto/ledger'; import type { DispatcherServices } from '@on.auto/operations'; import { shippedFilesOnDisk } from '@on.auto/operations/shipped-files-on-disk'; @@ -5,7 +6,9 @@ import { exitDeadlineMs, shutdownTimeoutMs } from '../lifecycle/shutdown.ts'; import type { ServerOptions } from './server-options.ts'; import { shippedRoots } from './shipped-roots.ts'; -export type ComposedServerOptions = ServerOptions; +export type ServerServices = DispatcherServices | LedgerConnection | LedgerChores; + +export type ComposedServerOptions = ServerOptions; export function serverOptionsComposing(composed: ServerOptions['composed']): ServerOptions { return { composed, shutdownTimeoutMs, exitDeadlineMs, shippedFiles: shippedFilesOnDisk(shippedRoots) }; diff --git a/packages/server/src/workflows/host-dependencies.test.ts b/packages/server/src/workflows/host-dependencies.test.ts index e480aefd7..6fda365ed 100644 --- a/packages/server/src/workflows/host-dependencies.test.ts +++ b/packages/server/src/workflows/host-dependencies.test.ts @@ -62,11 +62,9 @@ describe('the reports of the workflow host', () => { ]); }); - it('log trouble, a lost connection and a note of the host as warnings', async () => { + it('log trouble and a note of the host as warnings', async () => { const lines = await reportedLines(async (reports) => { await Effect.runPromise(reports.trouble('A sweep of the runs failed; the next sweep tries again', Cause.empty)); - reports.lostConnection(new Error('Connection terminated unexpectedly')); - await Effect.runPromise(Effect.yieldNow); await Effect.runPromise( reports.note({ kind: 'settled_after_back_off', @@ -78,7 +76,6 @@ describe('the reports of the workflow host', () => { expect(lines).toEqual([ expect.stringContaining('"message":"A sweep of the runs failed; the next sweep tries again","level":"WARN"'), - expect.stringContaining('"annotations":{"error":"Connection terminated unexpectedly"}'), expect.stringContaining('"message":"A run that could not be settled was settled at attempt 21"'), ]); }); diff --git a/packages/server/src/workflows/host-dependencies.ts b/packages/server/src/workflows/host-dependencies.ts index 517338668..a77316db0 100644 --- a/packages/server/src/workflows/host-dependencies.ts +++ b/packages/server/src/workflows/host-dependencies.ts @@ -1,6 +1,6 @@ import type { AppRuntime } from '@on.auto/api'; import type { Capability } from '@on.auto/definitions'; -import type { Dispatcher, DispatcherServices } from '@on.auto/operations'; +import type { Dispatcher } from '@on.auto/operations'; import { openWorkflowHost, type HostClock, @@ -10,7 +10,9 @@ import { type WorkflowStore, } from '@on.auto/workflow-host'; +import { orphansSweptIn } from '../database/ledger-database.ts'; import { logWorkflows } from '../logging/logging.ts'; +import type { ServerServices } from '../server-options/composed-options.ts'; import type { Settings } from '../settings/settings.ts'; import { hostReports } from './host-reports.ts'; import { hostWorkOf, type WorkParts } from './host-work.ts'; @@ -26,8 +28,10 @@ export interface HostParts { readonly clock?: HostClock; } +export type HostRuntime = AppRuntime; + export async function openedHost( - runtime: AppRuntime, + runtime: HostRuntime, dispatcher: Dispatcher, { workflows, capabilities, store, projector, dueWork, evaluations, clock }: HostParts, { startVersion, names }: Pick, @@ -35,7 +39,7 @@ export async function openedHost( const host = await openWorkflowHost({ database: store, projector, - dueWork, + dueWork: [...dueWork, await orphansSweptIn(runtime)], ...hostWorkOf(runtime, dispatcher, { capabilities, startVersion, evaluations, names }), reports: hostReports(runtime), context: runtime.context, diff --git a/packages/server/src/workflows/host-reports.ts b/packages/server/src/workflows/host-reports.ts index c0d62927b..cb9a5d42b 100644 --- a/packages/server/src/workflows/host-reports.ts +++ b/packages/server/src/workflows/host-reports.ts @@ -3,7 +3,7 @@ import type { DispatcherServices } from '@on.auto/operations'; import type { HostReports } from '@on.auto/workflow-host'; import { logHostNote } from '../logging/host-notes.ts'; -import { logLostWorkflowConnection, logUnsettled, logWorkflowTrouble } from '../logging/logging.ts'; +import { logUnsettled, logWorkflowTrouble } from '../logging/logging.ts'; import { inRuntime } from './in-runtime.ts'; const unsettledBecause = { @@ -16,9 +16,6 @@ export function hostReports(runtime: AppRuntime): HostReport unsettled: ({ org, playbook, runId, receipt }) => inRuntime(runtime, logUnsettled({ org, playbook, runId, reason: unsettledBecause[receipt] })), trouble: (what, cause) => inRuntime(runtime, logWorkflowTrouble(what, cause)), - lostConnection: (error) => { - void runtime.run(logLostWorkflowConnection(error)); - }, note: (note) => inRuntime(runtime, logHostNote(note)), }; } diff --git a/packages/server/src/workflows/in-runtime.ts b/packages/server/src/workflows/in-runtime.ts index 9b7810a68..de877aaab 100644 --- a/packages/server/src/workflows/in-runtime.ts +++ b/packages/server/src/workflows/in-runtime.ts @@ -1,10 +1,9 @@ import type { AppRuntime } from '@on.auto/api'; -import type { DispatcherServices } from '@on.auto/operations'; import { Effect, Exit, Option } from 'effect'; -export function inRuntime( - runtime: AppRuntime, - work: Effect.Effect, +export function inRuntime( + runtime: AppRuntime, + work: Effect.Effect, ): Effect.Effect { return Effect.gen(function* () { const parent = yield* Effect.option(Effect.currentParentSpan); diff --git a/packages/server/src/workflows/workflows.ts b/packages/server/src/workflows/workflows.ts index d8a7ecf48..17d5693c0 100644 --- a/packages/server/src/workflows/workflows.ts +++ b/packages/server/src/workflows/workflows.ts @@ -1,20 +1,13 @@ -import type { AppRuntime } from '@on.auto/api'; import { callMarginMs, defineSendRunEvent, makeWorkflowAdapter, type ExpressionCheck } from '@on.auto/coordination'; import { defineStartVersion, type PlaybookOwnNames, type Capability, type CapabilityFacts } from '@on.auto/definitions'; -import { - makeCatalog, - makeDispatcher, - type DispatcherServices, - type RouteRegistration, - type ShippedFiles, -} from '@on.auto/operations'; +import { makeCatalog, makeDispatcher, type RouteRegistration, type ShippedFiles } from '@on.auto/operations'; import type { WorkflowHost } from '@on.auto/workflow-host'; import { Effect } from 'effect'; import { playbookOperationsServing } from '../composition/playbook-operations.ts'; import { routesFor, type ServedCatalog } from '../composition/served-routes.ts'; import type { Served } from '../server-options/server-options.ts'; -import { openedHost, type HostParts } from './host-dependencies.ts'; +import { openedHost, type HostParts, type HostRuntime } from './host-dependencies.ts'; interface OrgOperation { readonly registration: RouteRegistration<'org'>; @@ -49,7 +42,7 @@ function onceOpened(opening: Promise): Pick }; } -export async function serveWorkflows(runtime: AppRuntime, parts: WorkflowParts): Promise { +export async function serveWorkflows(runtime: HostRuntime, parts: WorkflowParts): Promise { const dispatcher = makeDispatcher([]); const opening = Promise.withResolvers(); const { names } = parts; diff --git a/packages/workflow-host/README.md b/packages/workflow-host/README.md index afd1f9ac8..a0ada689d 100644 --- a/packages/workflow-host/README.md +++ b/packages/workflow-host/README.md @@ -4,7 +4,7 @@ The workflow engine of [`@on.auto/workflow-engine`](../workflow-engine) on Node: ## Entry -`@on.auto/workflow-host` (`src/index.ts`) exports `openWorkflowStore(settings, lostConnection, folders)`, which opens the host's database and migrates its tables and the tables of the folders it is given, answering a `WorkflowStore` with the database, `viewsPortOn(database)`, the `ViewsPort` of [the views of recall functions](#the-views-of-recall-functions) on that database, and `openWorkflowHost(options)`, which opens the host's database, or takes the store it is given, migrates its tables, takes its place in the ledger (see [The follower](#the-follower)), claims the database's workflows (see [One host for a database](#one-host-for-a-database)), starts its loop if it holds them, and answers with a `WorkflowHost`: +`@on.auto/workflow-host` (`src/index.ts`) exports `openWorkflowStore(connection, folders)`, which makes the host's tables and the tables of the folders it is given in the ledger's database, through the connection of the ledger it is given, `LedgerConnection` of `@on.auto/ledger` (see [Where a run is kept](#where-a-run-is-kept)), answering a `WorkflowStore` with the database, `viewsPortOn(database)`, the `ViewsPort` of [the views of recall functions](#the-views-of-recall-functions) on that database, and `openWorkflowHost(options)`, which opens its store over the connection of the ledger it is given, or takes the store it is given, takes its place in the ledger (see [The follower](#the-follower)), claims the database's workflows (see [One host for a database](#one-host-for-a-database)), starts its loop if it holds them, and answers with a `WorkflowHost`: - `start(run, start)`: starts the run's log with the `started` input, and answers `started`; `going` when the run started before and is not settled, so nothing starts again, after trying again at once the settlement of a run that ended; or `settled` when the run ended and was settled, since a run never starts twice in one log. - `deliver(run, event)`: gives the run an `event_received` input, and answers `delivered`, also for an event the run took before; `not_started` for a run whose `started` has not arrived; or `ended` for a run that ended. @@ -15,14 +15,14 @@ Both fail with the engine's `Conflict` when the run's log kept changing while an | Option | What it is | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `database` | `{ store: 'sqlite', file }` or `{ store: 'postgresql', pools }`, the database the ledger is kept in, or a `WorkflowStore` opened on it | +| `database` | the connection of the ledger, a `ConnectionPorts` of `@on.auto/ledger`, or a `WorkflowStore` opened over it | | `projector` | optional `ProjectorSettings`: the projector, with [its folders](#the-folders-of-the-projector), that keeps their rows, the views of recall functions among them, while the host holds the database's workflows | | `machine` | the machine's options: the functions a workflow may call and the runtime its expressions see | | `perform` | `(call, run) => Effect`: what a call does, a `CallResult` or `{ status: 'waiting', child }` for a call whose run finishes later; it never fails, a function that cannot answer answers `unreachable` | | `waiting` | what [waiting calls](#waiting-calls) need: `resultOf`, the call's answer for the ending of the run it waits for, `cancel`, which records a cancel on a run, and `cancelDeferred`, which settles a cancelled run of another capability | | `mostOpenCalls` | how many calls may be open under one run at the top of a tree, `mostOpenCallsOfATree`, 1,000, unless given | | `settle` | `SettleRun` of `@on.auto/definitions`: how a workflow's outcome is recorded on its run | -| `reports` | where the host tells the operator of a run it could not settle (`unsettled`), of a failure it retries (`trouble`), of a lost connection, and its notes (`note`) | +| `reports` | where the host tells the operator of a run it could not settle (`unsettled`), of a failure it retries (`trouble`), and its notes (`note`) | | `sweepEveryMs` | how often the loop sweeps, 1,000 in the server | | `mostCallsAtOnce` | how many calls run at once | | `clock`, `cacheBounds` | the clock, `Date.now` unless given, and the bounds of the engine's cache of loaded runs, `runCacheBounds` unless given | @@ -56,7 +56,7 @@ A run is addressed by its playbook and its run id, `{ org, playbook, runId }`, s The log is written with the ledger's own append, `eventAppenderOf` over the event store, with the lineage of each record (see [The lineage of a run's records](#the-lineage-of-a-runs-records)), and read with its stream read, `EventStore.read`, so the history of a run (`get_run_history`, which reads `run-logs/` beside `runs/`) and the events of the playbook find it, and the ledger's rule holds: the ledger writes no SQL of its own on its write path, and the host owns its tables. -The tables are in the ledger's own database: the same SQLite file, opened through the same `sqlite3` library the ledger uses, or the same PostgreSQL database. `behindRuns` joins the watermarks to Emmett's `emt_streams`, and a join needs both in one database. One SQLite library in the process also keeps clear of what was found before the host was built: a second SQLite library writing to the ledger's file lost committed writes. The host opens its own connections from the settings the ledger is opened from (`LEDGER_FILE` or `DATABASE_URL`): on SQLite one pool, its writer serialising the host's writes and its run logs' appends, and on PostgreSQL two pools opened from the `pools` it is given, a `PostgreSQLPools` of `@on.auto/ledger/postgresql`: the event store's, and one of at most four connections for its tables. How a connection signs in is the pools' own, the same as the ledger's. The host loads the module of the database of its store alone, `src/database/sqlite-database.ts` or `src/database/postgresql-database.ts`, through a dynamic import when it first opens one, so a host on PostgreSQL never loads `sqlite3` and one on SQLite never loads the PostgreSQL entry of the ledger (`src/database/store-modules.test.ts`). It creates its tables, and the tables of the folders of its projector, with `CREATE TABLE IF NOT EXISTS` when it opens, on PostgreSQL in one transaction under an advisory lock of its own. Beside `read` and `write`, each of one statement, the database has `transaction(statements)`, which runs the statements in order in one transaction, on SQLite on the pool's writer and on PostgreSQL on one connection, and answers what the last one answers: it keeps them only when the last writes a row, so a write that ends with a statement conditional on what it read keeps nothing once another host wrote first and answers no row, and it keeps none when one fails, failing with the driver's error. SQLite has no statement that writes several tables at once, as PostgreSQL's data-modifying `WITH` does, so a write of several statements that must stand or fall together is a transaction on both. `readConsistently(reads)` gives the reads it runs one snapshot of the database, so that what they read together is of one moment however long they take and whatever is written meanwhile: on SQLite a deferred transaction on one of the pool's readers, which in its write-ahead log sees what was committed when its first read began and nothing written after, and, in a private database in memory, a deferred transaction on the pool's one connection, which holds the host's writes until it ends; on PostgreSQL one connection in a transaction `REPEATABLE READ READ ONLY`. It ends without keeping anything and answers what the reads answer, or fails as they fail. `LEDGER_FILE=:memory:` opens a private database in memory, so the host's runs are not in the ledger's; it is for tests. +The tables are in the ledger's own database, which the ledger has opened by the time the host opens its store: the host takes the connection of the ledger, the `LedgerConnection` its layers provide beside `Ledger` (see [The connection of the ledger](../ledger/README.md#the-connection-of-the-ledger)), and runs every statement of its tables, and the appends and reads of its run logs, through it, whatever store the ledger is kept in. The host opens no database of its own, closes none, loads no driver and names no store (`src/database/store-modules.test.ts`). `behindRuns` joins the watermarks to Emmett's `emt_streams`, and a join needs both in one database. On SQLite the connection is the ledger's one pool, its writer serialising the ledger's writes, the host's and its run logs' appends, so one SQLite library in the process writes the file, which keeps clear of what was found before the host was built: a second SQLite library writing to the ledger's file lost committed writes. On PostgreSQL the connection runs the host's statements on a pool of at most four connections the ledger keeps for the statements beside it, and the run logs on the ledger's own pool. The host speaks the dialect the connection names, `sqlite` or `postgresql`, only to add the columns of tables made before them (below); every other statement is the same on both. It creates its tables, and the tables of the folders of its projector, with `CREATE TABLE IF NOT EXISTS` when it opens its store, through the connection's `migrated`, on PostgreSQL in one transaction under an advisory lock of the connection's own. Beside `read` and `write`, each of one statement, the database has `transaction(statements)`, which runs the statements in order in one transaction, on SQLite on the pool's writer and on PostgreSQL on one connection, and answers what the last one answers: it keeps them only when the last writes a row, so a write that ends with a statement conditional on what it read keeps nothing once another host wrote first and answers no row, and it keeps none when one fails, failing with the driver's error. SQLite has no statement that writes several tables at once, as PostgreSQL's data-modifying `WITH` does, so a write of several statements that must stand or fall together is a transaction on both. `readConsistently(reads)` gives the reads it runs one snapshot of the database, so that what they read together is of one moment however long they take and whatever is written meanwhile: on SQLite a deferred transaction on one of the pool's readers, which in its write-ahead log sees what was committed when its first read began and nothing written after, and, in a private database in memory, a deferred transaction on the pool's one connection, which holds the host's writes until it ends; on PostgreSQL one connection in a transaction `REPEATABLE READ READ ONLY`. It ends without keeping anything and answers what the reads answer, or fails as they fail. `LEDGER_FILE=:memory:` opens a private database in memory, whose one connection the host shares with the ledger; it is for tests. ### The lineage of a run's records @@ -97,12 +97,14 @@ What the host does when it wakes and what wakes it are two things. The **wake-up The **loop** (`src/loop/host-loop.ts`) is what wakes it in the server, one clock among those that could: `startLoop(parts, wakeUpWith)` makes the wake-up with the signal it keeps, wakes it at once when it starts, asks it to sweep every `sweepEveryMs`, and after each wake-up sleeps, on the host's `clock`, until the `nextDueAt` it answered or the next sweep, whichever comes first; a timer armed earlier than that, and the signal raised, wake it early. Any clock may drive the wake-up instead of the loop: it opens the parts over the store with the time it keeps as their `clock`, makes the wake-up with a signal of its own, calls `wakeUp({ now, sweep })` at the moments it chooses, sooner when `armed` or its signal says so, asks it to sweep from time to time, as the loop does every `sweepEveryMs`, since only a sweep resumes calls and catches up the runs whose dispatch fell behind, and stops the wake-up and closes the parts when it is done. The claim on the database's workflows is no part of the parts or of a wake-up: the host takes it, renews it every sweep, and opens the parts and runs the loop only while it holds it (see [One host for a database](#one-host-for-a-database)), so a clock that drives parts it opened alone must itself make sure that nothing else serves the database's workflows meanwhile. -`stop()` first stops the follower of the playbooks once the round of work it is in has finished, so a run a reaction is starting is started and not refused, then refuses new starts and events, waits for the starts and events it already took, lets the round of the wake-up in progress finish, so the decisions it takes are appended and dispatched, interrupts the calls in progress, which stay recorded as running and start again at the next start, while a waiting call stays waiting, lets go of its claim, and closes the database; once it stopped, no call begins. Everything else is left for the next start: runs whose dispatch fell behind are swept, overdue timers fire, and calls resume. +`stop()` first stops the follower of the playbooks once the round of work it is in has finished, so a run a reaction is starting is started and not refused, then refuses new starts and events, waits for the starts and events it already took, lets the round of the wake-up in progress finish, so the decisions it takes are appended and dispatched, interrupts the calls in progress, which stay recorded as running and start again at the next start, while a waiting call stays waiting, and lets go of its claim; the ledger's connection is the ledger's to close, after the host stopped. Once it stopped, no call begins. Everything else is left for the next start: runs whose dispatch fell behind are swept, overdue timers fire, and calls resume. ### Due rows A projection of runs that is a schedule, as the open requests of interaction functions are, registers a `DueWork` with the host: its `name`, `due(now, most, callsOut)`, at most `most` of the items due by now whose perform calls out of the server, as a delivery does, or of the others, each with a `key`, a `perform(now)` and `callsOut`, which an item read as calling out may have lost since, as a delivery whose request expired, and `nextDueAt(after)`, the soonest due time of either after a moment. Each wake-up reads the two apart, so neither waits behind the other however many of one are due, hands out the due items first and performs them in the background, as the executor performs workflow calls, in two lanes of each work, each 16 at once (`duePerformedAtOnce`): the items that call out, at most 16 in flight, so none waits for a place holding a row that may have changed since it was read, and the rest, such as an expiry or a settlement, at most 256 in flight (`dueInOneWakeUp`), so the rest never wait behind deliveries that hang. A wake-up waits a second at most (`dueAwaitedMs`), counted on the host's `clock` as the loop's sleeps are, for the items it handed out that call out nowhere, and not at all for those that call out, and then fires its timers, so a request that expired while no host ran ends expired before a workflow's timer of the same moment fires, a delivery, even through a tool that never answers, holds no timer, and any other perform that hangs holds the timers of one wake-up a second at most and those of the wake-ups after not at all. An item still in flight is not handed out again, and every item that ends raises the signal and wakes the loop, so the items behind it are handed out then; a wake-up that found more than a lane could take answers that it is due again at once while that lane has room. A perform that fails is reported and tried again after a wait kept in memory per key, a second, then twice as long each time up to five minutes, for at most 4,096 keys at once (`rowWaitBounds`), and the items behind it go on; a wake-up asks for as many more items as it holds back or has in flight, so neither fills a wake-up. Its `nextDueAt` is also the soonest due time after the moment it read its items, which is at once when that time came while it ran, or the time the first held item may be tried again, and the loop wakes it every sweep besides, so an item another process made due is performed within a sweep; a due time at or before that moment, as that of an item in flight, is no reason to wake, so while every due item is in flight or held back the loop sleeps until one ends, the first held may be tried again or the next sweep comes. A wake-up renews the signal an item's end raises before it reads when it is next due, so an item that ends during those reads wakes the loop too. Due work runs only on the host that holds the database's workflows, since the loop runs there alone, and nothing of it is kept but what the work's own perform records; a host that stops ends the performs in flight, which their work recovers as it recovers a stop. +A chore is due work of one item: `choreWork({ name, perform })` (`src/due-work/due-chores.ts`) hands out its `perform(now)` at the first wake-up, and again once the time it answered has come, in the lane of the items that call out, since a chore may, as a sweep of an object store does, so no wake-up waits for it; while it is under way, or before its time, it is no reason to wake. The server performs the ledger's sweep of orphaned content so, `sweepOrphans(now)` of the ledger's chores, which answers the next time it is due (see the [ledger README](../ledger/README.md#recorded-content)); like any due work it runs on the host that holds the database's workflows, which sweeps for every server on the database, and a host that stops waits for a sweep under way, since the sweep is not interrupted. + ## Telemetry `HostOptions.context`, the context of the server's runtime, is the context the host's fibers run in, so its spans reach the server's tracer; without it they run in an empty one. Each input submitted is a span named for its kind, a call `call ` beneath the input that made it, the answer and the `settle` beneath the call, a record the follower delivers `follow ` linked to the append that kept it, and a start that waited for a later minute `deferred_start`, linked the same way. Idle sweeps make no span. @@ -464,7 +466,7 @@ The suites of `src/views-testing` run on SQLite in `src/projector/views-on-sqlit The suites of `src/folder-testing` run on SQLite in `src/projector/folders-on-sqlite.test.ts` and on PostgreSQL in `src/projector/folders-on-postgresql.test.ts`: a second folder, which counts the events of a type into tables of its own, in the host's own process, keyed by the part of its definition it counts, as the views of recall functions are folded beside it: `folder_tallies`, a row a tally with its count, and `folder_tally_outputs`, the count of each output, which a page adds to with one upsert an output before it moves its row's checkpoint, all in one transaction conditional on the checkpoint it read, as a dataset of prediction functions writes its rows. It shares one read a wake with the views once both are caught up; a row waits for its slot, rebuilds in it, goes live, and stalls at an event it cannot fold, counting no output after it; over a restart its row never leaves `live`, so a new projector folds only what came after its checkpoint, each event once; it rebuilds from the start when the part of a definition it is keyed by changes, while an edit that leaves that part alone and the row another definition still names stay live, never rebuilt; a row is dropped, its outputs first, in one transaction, once no definition names it; it takes no write, neither its outputs nor its row, from a pass that read its row before another wrote it, and keeps nothing of a write whose last statement fails after its outputs were written; and its tables are made on every open of the store, which finds them made and keeps their rows. With the views beside it at another checkpoint, it folds on alone while a views fold that overran rests, the views read alone from their own checkpoint at the next sweep, and the two share one read again once both are caught up. -`@on.auto/workflow-host/testing` exports `projectedFolders(settings, folders)` for the tests of a folder kept outside this package: it opens the store with those folders, appends records to it, starts and stops a projector over it with the sweep, pages and playbooks at once a test gives, and keeps the notes and the troubles the projector reported, as the dataset suites of `@on.auto/prediction` use it; `src/folder-testing/projected-folders.test.ts` runs it over the tally folder. +`@on.auto/workflow-host/testing` exports `projectedFolders(settings, folders)` for the tests of a folder kept outside this package: it opens a ledger on the `DatabaseSettings` it is given, `{ store: 'sqlite', file }` or `{ store: 'postgresql', pools }`, and the store with those folders over its connection, appends records to it, starts and stops a projector over it with the sweep, pages and playbooks at once a test gives, and keeps the notes and the troubles the projector reported, as the dataset suites of `@on.auto/prediction` use it; `src/folder-testing/projected-folders.test.ts` runs it over the tally folder. It also exports `workflowHostOnALedger(settings, options)`, a host opened on a ledger it opens on those settings and closes once the host stops, or once the host cannot open, as the tests of `@on.auto/coordination` open theirs (`src/ledger-testing/ledger-connections.test.ts`). The other tests run on SQLite alone, as the ledger's do for what is the same on both stores. The tests of the wake-up (`src/loop/host-wake-up.test.ts`) call it on its own, over timers kept in memory, the fake due work below and an engine that records its sweeps, on `steppedClock`, for each part of its round and for its `nextDueAt`, and those of the loop (`src/loop/host-loop.test.ts`) run it over the wake-up, on the system clock and the timers of a SQLite file. The test of the parts (`src/host/host-parts.test.ts`) opens them over a SQLite file with no loop and no claim and calls their wake-up twice on `steppedClock`, firing the timers due at each and answering when they are next due, and `src/host/workflow-host.test.ts` shows `openWorkflowHost` composing the three: it takes the claim, its loop wakes the parts when the clock is stepped, and it lets go of the claim when it stops. The tests of due rows that read when a timer fires, how long a wake-up waits or what a wake-up hands out (`src/due-work`) run the loop on `steppedClock` of `src/views-testing/stepped-sweeps.ts`, a clock whose sleeps end only when the test steps it, over fake due work whose performs the test may hold and let go (`fake-due-work.ts`), so what they read depends on that clock and never on how fast the machine runs. Among them `src/projector/projector-bounds.test.ts` reaches the projector's bounds, each with a smaller value of its setting so that it fails when the bound is removed: the pages of a pass, for a live view and for a rebuilding one, the playbooks passed at once, the half of the pool the projector holds, and a fold that ran past its deadline resting until the next sweep while its playbook's other views go on. @@ -478,4 +480,4 @@ docker rm --force workflow-host-pg ## Source -`src/database` opens the host's database on either store and holds its tables; `src/views` the table of the views, its rows, the views port and the views folder, which says which views their definitions want and writes them; `src/projector` the projector, its schedule, the folder, the rows of a folder brought in line in their slots, and its passes over a playbook; `src/pages` the points of a playbook's history, a page of events read, and folded and turned into changes of the views; `src/runs` the run's address and its store; `src/dispatch` the watermark and the serialiser; `src/timers` the timers; `src/calls` the executor, its call rows and the cancels of calls; `src/waiting` the consumers of child endings and cancel requests, the cancels passed over and the first resume's give of them, and what the executor needs of waiting calls; `src/waiting-testing` what the tests of waiting calls share; `src/settlement` the record store and its back-off; `src/lease` the claim on the database's workflows and its keeper; `src/loop` the host's clock, the wake-up and the loop that wakes it; `src/due-work` the due rows the loop performs, the waits of rows that failed, and a fake work for the tests; `src/follower` the follower of the playbooks and its consumers' loop; `src/sweeps` what wakes the follower, the signals and the sweeps; `src/listeners` the listeners; `src/triggers` the rows of the triggers and their comparison at a definition record; `src/reactions` the starts, the offers and the refusals; `src/schedules` the schedules; `src/emissions` the emitter; `src/host` the host itself and its parts; `src/testing` what the tests share, the suites both stores run among it; `src/reaction-testing` what the tests of reactions share; `src/views-testing` the same for the views; and `src/folder-testing` the folder of the tests and its suite. +`src/database` holds the host's tables and runs its statements through the connection of the ledger; `src/ledger-testing` opens a ledger on the settings of a database, for the tests and the measurements, and a host or the host's database over its connection, closing the ledger with them; `src/views` the table of the views, its rows, the views port and the views folder, which says which views their definitions want and writes them; `src/projector` the projector, its schedule, the folder, the rows of a folder brought in line in their slots, and its passes over a playbook; `src/pages` the points of a playbook's history, a page of events read, and folded and turned into changes of the views; `src/runs` the run's address and its store; `src/dispatch` the watermark and the serialiser; `src/timers` the timers; `src/calls` the executor, its call rows and the cancels of calls; `src/waiting` the consumers of child endings and cancel requests, the cancels passed over and the first resume's give of them, and what the executor needs of waiting calls; `src/waiting-testing` what the tests of waiting calls share; `src/settlement` the record store and its back-off; `src/lease` the claim on the database's workflows and its keeper; `src/loop` the host's clock, the wake-up and the loop that wakes it; `src/due-work` the due rows the loop performs, the waits of rows that failed, and a fake work for the tests; `src/follower` the follower of the playbooks and its consumers' loop; `src/sweeps` what wakes the follower, the signals and the sweeps; `src/listeners` the listeners; `src/triggers` the rows of the triggers and their comparison at a definition record; `src/reactions` the starts, the offers and the refusals; `src/schedules` the schedules; `src/emissions` the emitter; `src/host` the host itself and its parts; `src/testing` what the tests share, the suites both stores run among it; `src/reaction-testing` what the tests of reactions share; `src/views-testing` the same for the views; and `src/folder-testing` the folder of the tests and its suite. diff --git a/packages/workflow-host/host-process.ts b/packages/workflow-host/host-process.ts index 826c1788c..d6d43bc38 100644 --- a/packages/workflow-host/host-process.ts +++ b/packages/workflow-host/host-process.ts @@ -3,9 +3,9 @@ import { setTimeout } from 'node:timers/promises'; import type { SettleRun } from '@on.auto/definitions'; import type { CallResult } from '@on.auto/operations'; -import { Effect, Function, Schema } from 'effect'; +import { Effect, Schema } from 'effect'; -import { openWorkflowHost, type WorkflowHost } from './src/host/workflow-host.ts'; +import { workflowHostOnALedger } from './src/ledger-testing/ledger-connections.ts'; import { recordedReactions } from './src/reaction-testing/recorded-reactions.ts'; import { startOf, testMachine } from './src/testing/host-documents.ts'; import { databaseSettingsOf, type ProcessDatabase } from './src/testing/host-processes.ts'; @@ -55,24 +55,22 @@ const settle: SettleRun = ({ id }, settlement) => } as const; }); -async function settledIn(host: WorkflowHost): Promise { +async function stoppedOnceSettled(stop: () => Promise): Promise { if (existsSync(settlementsFile) && readFileSync(settlementsFile, 'utf8') !== '') { - await host.stop(); + await stop(); return; } await setTimeout(20); - await settledIn(host); + await stoppedOnceSettled(stop); } -const host = await openWorkflowHost({ - database: databaseSettingsOf(database), +const host = await workflowHostOnALedger(databaseSettingsOf(database), { machine: testMachine, perform: () => (mode === 'hang-on-call' ? Effect.andThen(said('calling'), hangs) : answered), settle, reports: { unsettled: () => Effect.void, trouble: () => Effect.void, - lostConnection: Function.constVoid, note: () => Effect.void, }, sweepEveryMs: Number(sweepEveryMs), @@ -82,7 +80,7 @@ const host = await openWorkflowHost({ }); if (mode === 'finish') { - await settledIn(host); + await stoppedOnceSettled(host.stop); } else { await Effect.runPromise(host.start(run, { ...startOf(notifying), attributes: {} })); } diff --git a/packages/workflow-host/measure/finish-catch-up.ts b/packages/workflow-host/measure/finish-catch-up.ts index 5cdaaf276..8d817d645 100644 --- a/packages/workflow-host/measure/finish-catch-up.ts +++ b/packages/workflow-host/measure/finish-catch-up.ts @@ -1,10 +1,9 @@ import { setTimeout } from 'node:timers/promises'; -import { recordedReaderOf } from '@on.auto/ledger'; -import { Effect, Function, Schema } from 'effect'; +import { recordedReaderOf, statement } from '@on.auto/ledger'; +import { Effect, Schema } from 'effect'; -import { openHostDatabase, type DatabaseSettings } from '../src/database/host-databases.ts'; -import { statement } from '../src/database/statement.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { alpha, at, playbookCreated, recorded } from '../src/reaction-testing/playbook-writes.ts'; import { measuredHost, runAt } from './measured-host.ts'; @@ -13,7 +12,7 @@ export interface FinishCatchUp { readonly milliseconds: number; } -type Opened = Awaited>; +type Opened = Awaited>; const Places = Schema.Array(Schema.Struct({ cursor: Schema.NullOr(Schema.String) })); @@ -61,7 +60,7 @@ async function finishedInTurn(opened: Opened, count: number, index = 0): Promise } export async function finishCatchUpOn(database: DatabaseSettings, finishes: number): Promise { - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); await playbookCreated(opened.store, 'alpha'); const following = await measuredHost(database); await untilPlaced(opened, (places) => places.length > 0); diff --git a/packages/workflow-host/measure/first-resume.ts b/packages/workflow-host/measure/first-resume.ts index 4ff4eaa1b..0604b3d6b 100644 --- a/packages/workflow-host/measure/first-resume.ts +++ b/packages/workflow-host/measure/first-resume.ts @@ -1,6 +1,6 @@ -import { Effect, Function, type Schema } from 'effect'; +import { Effect, type Schema } from 'effect'; -import { openHostDatabase, type DatabaseSettings } from '../src/database/host-databases.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { bareEngineOn } from '../src/waiting-testing/crashed-host.ts'; import { passedOverRow, pendingCancelRowsAfter } from '../src/waiting/pending-cancel-rows.ts'; import { pendingCancelsGivenOnce } from '../src/waiting/pending-cancels.ts'; @@ -39,7 +39,7 @@ export async function firstResumeOn(database: DatabaseSettings, going: number, p const started = performance.now(); await goingRuns(database, going); const startedMs = performance.now() - started; - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); await Effect.runPromise( Effect.forEach( Array.from({ length: pending }, (_, index) => index), diff --git a/packages/workflow-host/measure/idle-views.ts b/packages/workflow-host/measure/idle-views.ts index c9a245bd8..4e9b44624 100644 --- a/packages/workflow-host/measure/idle-views.ts +++ b/packages/workflow-host/measure/idle-views.ts @@ -1,10 +1,10 @@ import { setTimeout } from 'node:timers/promises'; -import { Effect, Function } from 'effect'; +import { statement } from '@on.auto/ledger'; +import { Effect } from 'effect'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; -import { statement } from '../src/database/statement.ts'; import { openWorkflowStore, type WorkflowStore } from '../src/host/workflow-store.ts'; +import { ledgerConnectedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { systemClock } from '../src/loop/host-clock.ts'; import { startProjector } from '../src/projector/projector.ts'; import { measuredViewOf, measuredViews, measuringPool, recallContentOf } from './measured-views.ts'; @@ -63,7 +63,8 @@ async function allLive(store: WorkflowStore, views: number): Promise { export async function idleViewsOn(settings: DatabaseSettings, views: number, seconds: number): Promise { const pool = measuringPool(); const measured = measuredViews(pool); - const store = await openWorkflowStore(settings, Function.constVoid, measured.folders); + const ledger = await ledgerConnectedOn(settings); + const store = await openWorkflowStore(ledger.connection, measured.folders); await savedInTurn(store, views); const projector = startProjector({ database: store.database, @@ -71,7 +72,6 @@ export async function idleViewsOn(settings: DatabaseSettings, views: number, sec reports: { unsettled: () => Effect.void, trouble: () => Effect.void, - lostConnection: Function.constVoid, note: () => Effect.void, }, clock: systemClock, @@ -85,6 +85,6 @@ export async function idleViewsOn(settings: DatabaseSettings, views: number, sec const busy = performance.eventLoopUtilization(before); await projector.stop(); await pool.close(); - await store.database.close(); + await ledger.close(); return { views, viewBytes: view.length, seconds, busyMsASecond: busy.utilization * 1000 }; } diff --git a/packages/workflow-host/measure/lateness.ts b/packages/workflow-host/measure/lateness.ts index b96b5fadd..ee141933e 100644 --- a/packages/workflow-host/measure/lateness.ts +++ b/packages/workflow-host/measure/lateness.ts @@ -1,7 +1,6 @@ -import { Effect, Function } from 'effect'; +import { Effect } from 'effect'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; -import { openHostDatabase } from '../src/database/host-databases.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { playbookCreated, definitionRecorded } from '../src/reaction-testing/playbook-writes.ts'; import { ledgerRunLogStore } from '../src/runs/ledger-run-store.ts'; import { runKeyOf } from '../src/runs/run-address.ts'; @@ -22,7 +21,7 @@ function percentile(sorted: readonly number[], fraction: number): number { } async function latenessOf(database: DatabaseSettings, runs: number): Promise { - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); const runStore = ledgerRunLogStore(opened); const late = await Effect.runPromise( Effect.forEach( @@ -42,7 +41,7 @@ async function latenessOf(database: DatabaseSettings, runs: number): Promise { - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); await playbookCreated(opened.store, 'alpha'); await Array.from({ length: count }, (_, index) => index).reduce>( (before, index) => diff --git a/packages/workflow-host/measure/measured-host.ts b/packages/workflow-host/measure/measured-host.ts index 44c7a7078..9f1264d6f 100644 --- a/packages/workflow-host/measure/measured-host.ts +++ b/packages/workflow-host/measure/measured-host.ts @@ -1,11 +1,11 @@ import { setTimeout } from 'node:timers/promises'; import type { Run, SettleRun } from '@on.auto/definitions'; -import { Effect, Function, type Schema } from 'effect'; +import { Effect, type Schema } from 'effect'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; import type { RunStart } from '../src/host/run-requests.ts'; -import { openWorkflowHost, type WorkflowHost } from '../src/host/workflow-host.ts'; +import type { WorkflowHost } from '../src/host/workflow-host.ts'; +import { workflowHostOnALedger, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import type { HostClock } from '../src/loop/host-clock.ts'; import { recordedReactions } from '../src/reaction-testing/recorded-reactions.ts'; import { startOf as testStartOf, testMachine } from '../src/testing/host-documents.ts'; @@ -44,15 +44,13 @@ export async function measuredHost(database: DatabaseSettings, clock?: HostClock counts.settled += 1; return run; }); - const host = await openWorkflowHost({ - database, + const host = await workflowHostOnALedger(database, { machine: testMachine, perform: () => Effect.succeed({ status: 'succeeded', output: null }), settle, reports: { unsettled: () => Effect.void, trouble: Effect.logWarning, - lostConnection: Function.constVoid, note: () => Effect.void, }, sweepEveryMs: 1000, diff --git a/packages/workflow-host/measure/open-calls.ts b/packages/workflow-host/measure/open-calls.ts index e766bbee1..06d6b5790 100644 --- a/packages/workflow-host/measure/open-calls.ts +++ b/packages/workflow-host/measure/open-calls.ts @@ -1,7 +1,7 @@ -import { Effect, Function } from 'effect'; +import { Effect } from 'effect'; import { openCallsUnder, startedRow } from '../src/calls/call-rows.ts'; -import { openHostDatabase, type DatabaseSettings } from '../src/database/host-databases.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; export interface OpenCallsCount { readonly underTheRoot: number; @@ -34,7 +34,7 @@ function percentile(sorted: readonly number[], fraction: number): number { } export async function openCallsCountOn(database: DatabaseSettings, underTheRoot: number): Promise { - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); const roots = ['root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-6', 'root-7', 'root-8', 'root-9']; await Effect.runPromise( Effect.forEach(roots, (root) => diff --git a/packages/workflow-host/measure/reactions.ts b/packages/workflow-host/measure/reactions.ts index 63ca4cc82..8bf16b878 100644 --- a/packages/workflow-host/measure/reactions.ts +++ b/packages/workflow-host/measure/reactions.ts @@ -3,9 +3,8 @@ import { setTimeout } from 'node:timers/promises'; import { streamSignalOf } from '@on.auto/ledger'; import { Effect, Function } from 'effect'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; -import { openHostDatabase } from '../src/database/host-databases.ts'; import { openWorkflowHost } from '../src/host/workflow-host.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { playbookCreated, eventRecordOf, @@ -35,7 +34,6 @@ export interface LatencyCase { const quiet = { unsettled: () => Effect.void, trouble: Effect.logWarning, - lostConnection: Function.constVoid, note: () => Effect.void, }; @@ -91,10 +89,10 @@ export async function reactionLatencyOn(database: DatabaseSettings, measured: La const startedAt = new Map(); const publishedAt = new Map(); const reactions = recordedReactions(); - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); await playbookCreated(opened.store, 'alpha'); const host = await openWorkflowHost({ - database, + database: { database: opened }, machine: testMachine, perform: () => Effect.succeed({ status: 'succeeded', output: null }), settle: () => Effect.die(new Error('The measured runs settle nothing')), diff --git a/packages/workflow-host/measure/rebuild.ts b/packages/workflow-host/measure/rebuild.ts index 1c752a6dc..9c98ab60c 100644 --- a/packages/workflow-host/measure/rebuild.ts +++ b/packages/workflow-host/measure/rebuild.ts @@ -1,10 +1,10 @@ import { setTimeout } from 'node:timers/promises'; -import { Effect, Function } from 'effect'; +import { Effect } from 'effect'; import type { HostDatabase } from '../src/database/host-database.ts'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; import { openWorkflowStore } from '../src/host/workflow-store.ts'; +import { ledgerConnectedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { systemClock } from '../src/loop/host-clock.ts'; import { startProjector } from '../src/projector/projector.ts'; import { reviewsFold } from '../src/views-testing/campaign-reviews.ts'; @@ -108,7 +108,8 @@ function counted(database: HostDatabase): Counted { export async function rebuildOn(settings: DatabaseSettings, events: number): Promise { const pool = measuringPool(); const measured = measuredViews(pool); - const store = await openWorkflowStore(settings, Function.constVoid, measured.folders); + const ledger = await ledgerConnectedOn(settings); + const store = await openWorkflowStore(ledger.connection, measured.folders); await recorded(store.database, events); const counting = counted(store.database); const started = performance.now(); @@ -118,7 +119,6 @@ export async function rebuildOn(settings: DatabaseSettings, events: number): Pro reports: { unsettled: () => Effect.void, trouble: () => Effect.void, - lostConnection: Function.constVoid, note: () => Effect.void, }, clock: systemClock, @@ -128,7 +128,7 @@ export async function rebuildOn(settings: DatabaseSettings, events: number): Pro const milliseconds = performance.now() - started; await projector.stop(); await pool.close(); - await store.database.close(); + await ledger.close(); return { events, pages: counting.pages(), diff --git a/packages/workflow-host/measure/recovery.ts b/packages/workflow-host/measure/recovery.ts index e98cfbd04..e81cf37a9 100644 --- a/packages/workflow-host/measure/recovery.ts +++ b/packages/workflow-host/measure/recovery.ts @@ -1,8 +1,7 @@ -import { Effect, Function, Schema } from 'effect'; +import { statement } from '@on.auto/ledger'; +import { Effect, Schema } from 'effect'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; -import { openHostDatabase } from '../src/database/host-databases.ts'; -import { statement } from '../src/database/statement.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { header, measuredHost, runAt, startOf } from './measured-host.ts'; export interface Recovery { @@ -28,7 +27,7 @@ function millisecondsOf(work: () => Promise): Promise { } async function snapshotBytesOf(database: DatabaseSettings): Promise { - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); const rows = await Effect.runPromise( opened.read(statement`SELECT SUM(bytes) AS bytes FROM workflow_snapshot_chunks`), ); diff --git a/packages/workflow-host/measure/schedule-firing.ts b/packages/workflow-host/measure/schedule-firing.ts index be440d7af..c7163ff30 100644 --- a/packages/workflow-host/measure/schedule-firing.ts +++ b/packages/workflow-host/measure/schedule-firing.ts @@ -1,9 +1,9 @@ import { setTimeout } from 'node:timers/promises'; -import { Effect, Function } from 'effect'; +import { Effect } from 'effect'; -import { openHostDatabase, type DatabaseSettings } from '../src/database/host-databases.ts'; import { openWorkflowHost } from '../src/host/workflow-host.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { playbookCreated, everyTrigger, definitionRecorded } from '../src/reaction-testing/playbook-writes.ts'; import { recordedReactions } from '../src/reaction-testing/recorded-reactions.ts'; import { testMachine } from '../src/testing/host-documents.ts'; @@ -22,7 +22,6 @@ const aMinute = 60_000; const quiet = { unsettled: () => Effect.void, trouble: Effect.logWarning, - lostConnection: Function.constVoid, note: () => Effect.void, }; @@ -39,10 +38,10 @@ async function untilFired(fired: () => number, most: number): Promise { export async function everyFiringOn(database: DatabaseSettings, schedules: number): Promise { const firedAt: number[] = []; - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); await playbookCreated(opened.store, 'alpha'); const host = await openWorkflowHost({ - database, + database: { database: opened }, machine: testMachine, perform: () => Effect.succeed({ status: 'succeeded', output: null }), settle: () => Effect.die(new Error('The measured runs settle nothing')), diff --git a/packages/workflow-host/measure/stores.ts b/packages/workflow-host/measure/stores.ts index 1c35f2509..96bde812b 100644 --- a/packages/workflow-host/measure/stores.ts +++ b/packages/workflow-host/measure/stores.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { plainPoolsOn } from '@on.auto/ledger/postgresql'; import { Client } from 'pg'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; +import type { DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; export interface MeasuredStore { readonly store: string; diff --git a/packages/workflow-host/measure/sweeps.ts b/packages/workflow-host/measure/sweeps.ts index 52c0fef11..235367770 100644 --- a/packages/workflow-host/measure/sweeps.ts +++ b/packages/workflow-host/measure/sweeps.ts @@ -1,9 +1,9 @@ import { Effect, Function } from 'effect'; -import { openHostDatabase, type DatabaseSettings } from '../src/database/host-databases.ts'; import { followedPlaybooksOn } from '../src/follower/followed-playbooks.ts'; import { passOf } from '../src/follower/playbook-pass.ts'; import { playbookRecordsOf } from '../src/follower/playbook-records.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { definitionRecordsKeepingTheirOwnStops } from '../src/reaction-testing/own-stops.ts'; import { byAdmin, definitionRecorded, eventTrigger, published } from '../src/reaction-testing/playbook-writes.ts'; import { playbookSweepsOn, type PlaybookSweeps } from '../src/sweeps/playbook-sweeps.ts'; @@ -58,7 +58,7 @@ function medianOf(times: readonly number[]): number { } export async function sweepCostOn(database: DatabaseSettings, playbooks: number, reacting: number): Promise { - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); const followed = followedPlaybooksOn(opened); await inTurn(playbooks, async (index) => { await published(opened.store, { id: `e${index}`, type: 'com.measure.other' }, byAdmin, playbookKeyOf(index)); diff --git a/packages/workflow-host/measure/throughput.ts b/packages/workflow-host/measure/throughput.ts index 3caa13f8d..9d411b084 100644 --- a/packages/workflow-host/measure/throughput.ts +++ b/packages/workflow-host/measure/throughput.ts @@ -1,7 +1,7 @@ import { Effect, Schema } from 'effect'; import { parse } from 'yaml'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; +import type { DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { skippingClock } from '../src/loop/host-clock.ts'; import { header, measuredHost, runAt, startOf } from './measured-host.ts'; diff --git a/packages/workflow-host/measure/trigger-lines.ts b/packages/workflow-host/measure/trigger-lines.ts index 8c880e883..0a16c0c13 100644 --- a/packages/workflow-host/measure/trigger-lines.ts +++ b/packages/workflow-host/measure/trigger-lines.ts @@ -1,6 +1,6 @@ import { loadavg } from 'node:os'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; +import type { DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { timerLatenessOn } from './lateness.ts'; import { reactionLatencyOn } from './reactions.ts'; import { everyFiringOn } from './schedule-firing.ts'; diff --git a/packages/workflow-host/measure/waiting-host.ts b/packages/workflow-host/measure/waiting-host.ts index 3ff889540..1319812bf 100644 --- a/packages/workflow-host/measure/waiting-host.ts +++ b/packages/workflow-host/measure/waiting-host.ts @@ -2,11 +2,11 @@ import { setTimeout } from 'node:timers/promises'; import type { SettleRun } from '@on.auto/definitions'; import { streamSignalOf } from '@on.auto/ledger'; -import { Effect, Function } from 'effect'; +import { Effect } from 'effect'; import { waitingCallsOf } from '../src/calls/call-rows.ts'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; -import { openWorkflowHost, type WorkflowHost } from '../src/host/workflow-host.ts'; +import type { WorkflowHost } from '../src/host/workflow-host.ts'; +import { workflowHostOnALedger, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { recordedReactions } from '../src/reaction-testing/recorded-reactions.ts'; import { testMachine } from '../src/testing/host-documents.ts'; import { recordedWaiting } from '../src/waiting-testing/recorded-waiting.ts'; @@ -50,15 +50,13 @@ function settlingAt(settledAt: Map): SettleRun { export async function waitingHost(settings: DatabaseSettings, reads: Reads, signalled: boolean): Promise { const settledAt = new Map(); const reactions = recordedReactions().options; - const host = await openWorkflowHost({ - database: settings, + const host = await workflowHostOnALedger(settings, { machine: testMachine, perform: (call) => Effect.succeed({ status: 'waiting', child: childOf(call.key.runId) }), settle: settlingAt(settledAt), reports: { unsettled: () => Effect.void, trouble: Effect.logWarning, - lostConnection: Function.constVoid, note: () => Effect.void, }, sweepEveryMs: 1000, diff --git a/packages/workflow-host/measure/waiting-lines.ts b/packages/workflow-host/measure/waiting-lines.ts index 08def2676..5d4998f46 100644 --- a/packages/workflow-host/measure/waiting-lines.ts +++ b/packages/workflow-host/measure/waiting-lines.ts @@ -1,6 +1,6 @@ import { loadavg } from 'node:os'; -import type { DatabaseSettings } from '../src/database/host-databases.ts'; +import type { DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { finishCatchUpOn } from './finish-catch-up.ts'; import { firstResumeOn, type FirstResume } from './first-resume.ts'; import { openCallsCountOn } from './open-calls.ts'; diff --git a/packages/workflow-host/measure/waiting.ts b/packages/workflow-host/measure/waiting.ts index 39373eec8..aae922cef 100644 --- a/packages/workflow-host/measure/waiting.ts +++ b/packages/workflow-host/measure/waiting.ts @@ -1,6 +1,6 @@ import { Effect, Function, type Schema } from 'effect'; -import { openHostDatabase, type DatabaseSettings } from '../src/database/host-databases.ts'; +import { hostDatabaseOpenedOn, type DatabaseSettings } from '../src/ledger-testing/ledger-connections.ts'; import { alpha, at, playbookCreated, recorded } from '../src/reaction-testing/playbook-writes.ts'; import { header, runAt, startOf } from './measured-host.ts'; import { childOf, waitingHost, type WaitingHost } from './waiting-host.ts'; @@ -18,7 +18,7 @@ export interface WaitingCase { readonly oneAtATime: boolean; } -type Store = Awaited>['store']; +type Store = Awaited>['store']; const calling: Schema.JsonObject = { document: header, do: [{ ask: { call: 'notify', with: { to: 'ada' } } }] }; @@ -50,7 +50,7 @@ async function measuredOn( document: Schema.JsonObject, record: (store: Store, runId: string) => Promise, ): Promise { - const opened = await openHostDatabase(database, Function.constVoid); + const opened = await hostDatabaseOpenedOn(database); await playbookCreated(opened.store, 'alpha'); const waiting = await waitingHost(database, opened, signalled); await inTurn(runs, (index) => Effect.runPromise(waiting.host.start(runAt(index), startOf(document)))); diff --git a/packages/workflow-host/package.json b/packages/workflow-host/package.json index f1d0f6b01..ce81eff5f 100644 --- a/packages/workflow-host/package.json +++ b/packages/workflow-host/package.json @@ -15,8 +15,6 @@ "measure": "node measure.ts" }, "dependencies": { - "@event-driven-io/dumbo": "0.13.0-beta.56", - "@event-driven-io/emmett-sqlite": "0.43.0-beta.50", "@on.auto/definitions": "workspace:*", "@on.auto/json-schema": "workspace:*", "@on.auto/ledger": "workspace:*", @@ -24,13 +22,12 @@ "@on.auto/playbooks": "workspace:*", "@on.auto/workflow-engine": "workspace:*", "croner": "10.0.1", - "effect": "catalog:", - "pg": "8.23.1", - "sqlite3": "6.0.1" + "effect": "catalog:" }, "devDependencies": { "@types/pg": "8.23.1", "@vitest/coverage-v8": "catalog:", + "pg": "8.23.1", "vitest": "catalog:", "yaml": "2.9.1" } diff --git a/packages/workflow-host/src/calls/call-rows.ts b/packages/workflow-host/src/calls/call-rows.ts index 7918a2284..a37cb74b4 100644 --- a/packages/workflow-host/src/calls/call-rows.ts +++ b/packages/workflow-host/src/calls/call-rows.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import { CallResultSchema, type CallResult } from '@on.auto/operations'; import { CallKeySchema, type OutputOrigin, type RunContext, type StartCall } from '@on.auto/workflow-engine'; import { Effect, Schema } from 'effect'; import { oneRowOf, rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; const StartCallSchema = Schema.Struct({ kind: Schema.Literal('start_call'), diff --git a/packages/workflow-host/src/calls/first-resume.test.ts b/packages/workflow-host/src/calls/first-resume.test.ts index eb9965250..5726c2331 100644 --- a/packages/workflow-host/src/calls/first-resume.test.ts +++ b/packages/workflow-host/src/calls/first-resume.test.ts @@ -1,10 +1,10 @@ +import type { Statement } from '@on.auto/ledger'; import type { CallResult } from '@on.auto/operations'; import type { StartCall } from '@on.auto/workflow-engine'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import type { Statement } from '../database/statement.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostExecutor } from './host-executor.ts'; diff --git a/packages/workflow-host/src/calls/open-call-bound.test.ts b/packages/workflow-host/src/calls/open-call-bound.test.ts index 5e8455e9e..77593f12c 100644 --- a/packages/workflow-host/src/calls/open-call-bound.test.ts +++ b/packages/workflow-host/src/calls/open-call-bound.test.ts @@ -1,10 +1,10 @@ +import { type Statement, statement } from '@on.auto/ledger'; import type { CallResult } from '@on.auto/operations'; import type { StartCall } from '@on.auto/workflow-engine'; import { Deferred, Effect, Exit, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import type { HostDatabase } from '../database/host-database.ts'; -import { statement, type Statement } from '../database/statement.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { until } from '../testing/until.ts'; import { hostExecutor } from './host-executor.ts'; diff --git a/packages/workflow-host/src/calls/waiting-calls.test.ts b/packages/workflow-host/src/calls/waiting-calls.test.ts index ef8261b17..c5b51729f 100644 --- a/packages/workflow-host/src/calls/waiting-calls.test.ts +++ b/packages/workflow-host/src/calls/waiting-calls.test.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import { messageIdOf, type CallResult } from '@on.auto/operations'; import type { StartCall } from '@on.auto/workflow-engine'; import { Deferred, Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { statement } from '../database/statement.ts'; import { faultyDatabase } from '../testing/faulty-database.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { until } from '../testing/until.ts'; diff --git a/packages/workflow-host/src/database/consistent-reads.test.ts b/packages/workflow-host/src/database/consistent-reads.test.ts index 9c3b61d45..a4086b6b7 100644 --- a/packages/workflow-host/src/database/consistent-reads.test.ts +++ b/packages/workflow-host/src/database/consistent-reads.test.ts @@ -1,12 +1,12 @@ import { randomUUID } from 'node:crypto'; +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { Client } from 'pg'; import { describe, expect, it, onTestFinished } from 'vitest'; import { aSQLiteFile, onPostgreSQLAt, openedOn, type SettingsOf } from '../testing/host-files.ts'; import { rowsOf, WholeNumber, type HostDatabase, type ReadStatement } from './host-database.ts'; -import { statement } from './statement.ts'; const server = process.env['LEDGER_TEST_POSTGRESQL_URL'] ?? ''; diff --git a/packages/workflow-host/src/database/host-database.ts b/packages/workflow-host/src/database/host-database.ts index 41a9cd66a..c4f592826 100644 --- a/packages/workflow-host/src/database/host-database.ts +++ b/packages/workflow-host/src/database/host-database.ts @@ -1,14 +1,12 @@ -import type { EventStore } from '@on.auto/ledger'; +import type { ConnectionPorts, LedgerStreams, ReadRows, Statement } from '@on.auto/ledger'; import { Data, Effect, Schema } from 'effect'; -import type { Statement } from './statement.ts'; - export class DatabaseFailed extends Data.TaggedError('database_failed')<{ readonly detail: string }> {} export type ReadStatement = (statement: Statement) => Effect.Effect; export interface HostDatabase { - readonly store: EventStore; + readonly store: LedgerStreams; readonly read: ReadStatement; readonly write: (statement: Statement) => Effect.Effect; readonly transaction: (statements: readonly Statement[]) => Effect.Effect; @@ -16,34 +14,45 @@ export interface HostDatabase { reads: (read: ReadStatement) => Effect.Effect, ) => Effect.Effect; readonly sharedClock: Effect.Effect | null; - readonly close: () => Promise; } -export interface StatementAnswer { - readonly rows: readonly unknown[]; - readonly rowCount: number | null; -} +export const WholeNumber = Schema.Union([Schema.Finite, Schema.FiniteFromString]); -const nothingAnswered: StatementAnswer = { rows: [], rowCount: 0 }; +function failedWith(error: unknown): DatabaseFailed { + return new DatabaseFailed({ detail: String(error) }); +} -export const WholeNumber = Schema.Union([Schema.Finite, Schema.FiniteFromString]); +function asked(answered: () => Promise): Effect.Effect { + return Effect.tryPromise({ try: answered, catch: failedWith }); +} -export function lastAnswerOf( - statements: readonly Statement[], - answered: (statement: Statement) => Promise, -): Promise { - return statements.reduce(async (before: Promise, next: Statement) => { - await before; - return answered(next); - }, Promise.resolve(nothingAnswered)); +function readsOn(read: ReadRows): ReadStatement { + return (statement) => asked(() => read(statement)); } -export function wroteARow({ rowCount }: StatementAnswer): boolean { - return Number(rowCount) > 0; +function consistentReadsOn(connection: ConnectionPorts): HostDatabase['readConsistently'] { + return (reads) => + Effect.flatMap(Effect.context(), (context) => + Effect.flatten( + Effect.tryPromise({ + try: (signal) => + connection.readConsistently((read) => Effect.runPromiseExitWith(context)(reads(readsOn(read)), { signal })), + catch: failedWith, + }), + ), + ); } -export function failedWith(error: unknown): DatabaseFailed { - return new DatabaseFailed({ detail: String(error) }); +export function hostDatabaseOn(connection: ConnectionPorts): HostDatabase { + const { sharedClock } = connection; + return { + store: connection.streams, + read: readsOn(connection.read), + write: readsOn(connection.write), + transaction: (statements) => asked(() => connection.transaction(statements)), + readConsistently: consistentReadsOn(connection), + sharedClock: sharedClock === null ? null : asked(sharedClock), + }; } export function rowsOf( diff --git a/packages/workflow-host/src/database/host-databases.ts b/packages/workflow-host/src/database/host-databases.ts deleted file mode 100644 index 1924301b8..000000000 --- a/packages/workflow-host/src/database/host-databases.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { AppendObserver } from '@on.auto/ledger'; -import type { PostgreSQLPools } from '@on.auto/ledger/postgresql'; - -import type { HostDatabase } from './host-database.ts'; -import type { Statement } from './statement.ts'; - -export type DatabaseSettings = - | { readonly store: 'sqlite'; readonly file: string; readonly observer?: AppendObserver | undefined } - | { readonly store: 'postgresql'; readonly pools: PostgreSQLPools; readonly observer?: AppendObserver | undefined }; - -export async function openHostDatabase( - settings: DatabaseSettings, - reportLostConnection: (error: Readonly) => void, - tables: readonly Statement[] = [], -): Promise { - if (settings.store === 'sqlite') { - const { openSQLiteDatabase } = await import('./sqlite-database.ts'); - return openSQLiteDatabase(settings.file, tables, settings.observer); - } - const { openPostgreSQLDatabase } = await import('./postgresql-database.ts'); - return openPostgreSQLDatabase({ pools: settings.pools, reportLostConnection, tables, observer: settings.observer }); -} diff --git a/packages/workflow-host/src/database/host-tables.ts b/packages/workflow-host/src/database/host-tables.ts index 71ff16b03..f26ae764f 100644 --- a/packages/workflow-host/src/database/host-tables.ts +++ b/packages/workflow-host/src/database/host-tables.ts @@ -1,17 +1,18 @@ -import { statement, type Statement } from './statement.ts'; +import { statement, type ConnectionPorts, type Dialect, type Statement } from '@on.auto/ledger'; +import { Schema } from 'effect'; -export interface AddedColumn { +interface AddedColumn { readonly table: string; readonly column: string; readonly added: Statement; readonly addedWhenMissing: Statement; } -export function columnsOnSQLite(table: string): Statement { +function columnsOnSQLite(table: string): Statement { return statement`SELECT name FROM pragma_table_info(${table})`; } -export const addedColumns: readonly AddedColumn[] = [ +const addedColumns: readonly AddedColumn[] = [ { table: 'workflow_timers', column: 'armed_by', @@ -32,7 +33,7 @@ export const addedColumns: readonly AddedColumn[] = [ }, ]; -export const indexesOfAddedColumns: readonly Statement[] = [ +const indexesOfAddedColumns: readonly Statement[] = [ statement`CREATE INDEX IF NOT EXISTS workflow_calls_open_by_root ON workflow_calls (root_id) WHERE state IN ('running', 'waiting')`, statement`CREATE INDEX IF NOT EXISTS workflow_calls_waiting_by_run ON workflow_calls (run_key) WHERE state = 'waiting'`, @@ -130,7 +131,7 @@ const followerTables: readonly Statement[] = [ )`, ]; -export const hostTables: readonly Statement[] = [ +const hostTables: readonly Statement[] = [ statement`CREATE TABLE IF NOT EXISTS workflow_runs ( run_key TEXT NOT NULL PRIMARY KEY, stream_id TEXT NOT NULL, @@ -199,3 +200,36 @@ export const hostTables: readonly Statement[] = [ )`, ...followerTables, ]; + +const ColumnRows = Schema.Array(Schema.Struct({ name: Schema.String })); + +async function columnAddedOn(connection: ConnectionPorts, { table, column, added }: AddedColumn): Promise { + const columns = Schema.decodeUnknownSync(ColumnRows)(await connection.read(columnsOnSQLite(table))); + if (!columns.some(({ name }) => name === column)) { + await connection.write(added); + } +} + +type TablesMade = (connection: ConnectionPorts, tables: readonly Statement[]) => Promise; + +const tablesMadeIn: Readonly> = { + sqlite: async (connection, tables) => { + await connection.migrated([...hostTables, ...tables]); + await addedColumns.reduce>( + (before, column) => before.then(() => columnAddedOn(connection, column)), + Promise.resolve(), + ); + await connection.migrated(indexesOfAddedColumns); + }, + postgresql: (connection, tables) => + connection.migrated([ + ...hostTables, + ...tables, + ...addedColumns.map(({ addedWhenMissing }) => addedWhenMissing), + ...indexesOfAddedColumns, + ]), +}; + +export function hostTablesMadeOn(connection: ConnectionPorts, tables: readonly Statement[]): Promise { + return tablesMadeIn[connection.dialect](connection, tables); +} diff --git a/packages/workflow-host/src/database/observed-database.test.ts b/packages/workflow-host/src/database/observed-database.test.ts index f8c456a03..96b5de535 100644 --- a/packages/workflow-host/src/database/observed-database.test.ts +++ b/packages/workflow-host/src/database/observed-database.test.ts @@ -1,26 +1,21 @@ -import { Function } from 'effect'; -import { describe, expect, it, onTestFinished } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { openHostDatabase } from './host-databases.ts'; +import { openedOn } from '../testing/host-files.ts'; const stream = 'playbook/acme/alpha/run-logs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -describe('the store of a host database opened with an observer', () => { +describe('the store of a host database on a ledger opened with an observer', () => { it('tells the observer of each append it keeps', async () => { const told: string[] = []; - const database = await openHostDatabase( - { - store: 'sqlite', - file: ':memory:', - observer: { - appended: (appendedTo, events) => { - told.push(`${appendedTo} ${events.length}`); - }, + const database = await openedOn({ + store: 'sqlite', + file: ':memory:', + observer: { + appended: (appendedTo, events) => { + told.push(`${appendedTo} ${events.length}`); }, }, - Function.constVoid, - ); - onTestFinished(() => database.close()); + }); await database.store.append(stream, [{ type: 'counted', data: { n: 1 } }], { expectedVersion: 0, diff --git a/packages/workflow-host/src/database/postgresql-database.test.ts b/packages/workflow-host/src/database/postgresql-database.test.ts deleted file mode 100644 index d6063812f..000000000 --- a/packages/workflow-host/src/database/postgresql-database.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { EventStore, StoredRecord } from '@on.auto/ledger'; -import { unreachableDatabase } from '@on.auto/ledger/testing'; -import { Effect, Function } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import { databaseSettingsOf } from '../testing/host-processes.ts'; -import type { DatabaseFailed, HostDatabase, StatementAnswer } from './host-database.ts'; -import { openHostDatabase } from './host-databases.ts'; -import { addedColumns, hostTables, indexesOfAddedColumns } from './host-tables.ts'; -import { postgresqlDatabaseOn, type Connection, type Connections } from './postgresql-database.ts'; -import { statement, textOnPostgreSQL, type StatementValue } from './statement.ts'; - -interface Asked { - readonly text: string; - readonly values: readonly StatementValue[]; -} - -interface Telling { - readonly store: EventStore; - readonly connections: Connections; - readonly asked: () => readonly Asked[]; - readonly said: () => readonly string[]; -} - -function telling(failingOn = '', rowsOf = (text: string): readonly unknown[] => [{ answered: text }]): Telling { - const asked: Asked[] = []; - const said: string[] = []; - const saying = (line: string): Promise => { - said.push(line); - return Promise.resolve(); - }; - const answerOf = (text: string): StatementAnswer => { - const rows = rowsOf(text); - return { rows, rowCount: rows.length }; - }; - const connection: Connection = { - query: (text, values = []) => { - asked.push({ text, values }); - return text === failingOn ? Promise.reject(new Error(`${text} failed`)) : Promise.resolve(answerOf(text)); - }, - release: () => { - said.push('connection released'); - }, - }; - return { - store: { - mostEventsInOneAppend: 64, - pointLength: 2, - read: () => Promise.resolve({ version: 0, messages: [] }), - append: () => Promise.resolve(), - readRecorded: () => Promise.resolve({ records: [] }), - readRecordedEvent: (_, id) => Promise.resolve(new Map().get(id)), - readAppended: () => Promise.resolve({ streams: [], through: ['0', '0'], more: false }), - definitionStreams: () => Promise.resolve([]), - migrate: () => saying('store migrated'), - close: () => saying('store closed'), - }, - connections: { - query: (text, values) => { - asked.push({ text, values }); - return Promise.resolve(answerOf(text)); - }, - connect: () => Promise.resolve(connection), - end: () => saying('connections ended'), - }, - asked: () => asked, - said: () => said, - }; -} - -function clockOf(database: HostDatabase): Effect.Effect { - return database.sharedClock ?? Effect.die(new Error('The database has no clock of its own')); -} - -const tableOfAFolder = statement`CREATE TABLE IF NOT EXISTS folder_rows (playbook_key TEXT NOT NULL PRIMARY KEY)`; - -const migrationAsked: readonly Asked[] = [ - { text: 'BEGIN', values: [] }, - { text: 'SELECT pg_advisory_xact_lock($1)', values: [7_461_239_041] }, - ...hostTables.map((table) => ({ text: textOnPostgreSQL(table), values: [] })), - { text: textOnPostgreSQL(tableOfAFolder), values: [] }, - ...addedColumns.map(({ addedWhenMissing }) => ({ text: textOnPostgreSQL(addedWhenMissing), values: [] })), - ...indexesOfAddedColumns.map((index) => ({ text: textOnPostgreSQL(index), values: [] })), - { text: 'COMMIT', values: [] }, -]; - -const thirdTable = textOnPostgreSQL(hostTables[2] ?? statement``); - -describe('the host database on PostgreSQL, opening', () => { - it('migrates the event store, then its own tables and those of its folders in one transaction under a lock of the host', async () => { - const { store, connections, asked, said } = telling(); - - await postgresqlDatabaseOn(store, connections, [tableOfAFolder]); - - expect(asked()).toEqual(migrationAsked); - expect(said()).toEqual(['store migrated', 'connection released']); - }); - - it('rolls its migration back, and closes, when a table cannot be made', async () => { - const { store, connections, asked, said } = telling(thirdTable); - - await expect(postgresqlDatabaseOn(store, connections, [])).rejects.toThrow(`${thirdTable} failed`); - expect(asked().slice(-2)).toEqual([ - { text: thirdTable, values: [] }, - { text: 'ROLLBACK', values: [] }, - ]); - expect(said()).toEqual(['store migrated', 'connection released', 'store closed', 'connections ended']); - }); - - it('does not open on a server that cannot be reached', async () => { - const { url, port } = await unreachableDatabase(); - const settings = databaseSettingsOf({ store: 'postgresql', connectionString: url }); - - await expect(openHostDatabase(settings, Function.constVoid)).rejects.toThrow(`ECONNREFUSED 127.0.0.1:${port}`); - }); -}); - -describe('the host database on PostgreSQL, open', () => { - it('reads and writes with its values numbered, and closes the store and its connections', async () => { - const { store, connections, asked, said } = telling(); - const database = await postgresqlDatabaseOn(store, connections, []); - const migration = asked().length; - - const read = await Effect.runPromise(database.read(statement`SELECT ${1} AS one`)); - const written = await Effect.runPromise(database.write(statement`DELETE FROM workflow_due WHERE run_key = ${'a'}`)); - await database.close(); - - expect(asked().slice(migration)).toEqual([ - { text: 'SELECT $1 AS one', values: [1] }, - { text: 'DELETE FROM workflow_due WHERE run_key = $1', values: ['a'] }, - ]); - expect([read, written]).toEqual([ - [{ answered: 'SELECT $1 AS one' }], - [{ answered: 'DELETE FROM workflow_due WHERE run_key = $1' }], - ]); - expect(said().slice(-2)).toEqual(['store closed', 'connections ended']); - }); -}); - -const runsMoved = statement`UPDATE workflow_runs SET status = ${'ended'} WHERE run_key = ${'a'}`; - -const checkpointMoved = statement`UPDATE folder_rows SET checkpoint = ${'2'} WHERE playbook_key = ${'a'} AND checkpoint = ${'1'} - RETURNING playbook_key`; - -const transactionAsked: readonly Asked[] = [ - { text: 'BEGIN', values: [] }, - { text: textOnPostgreSQL(runsMoved), values: ['ended', 'a'] }, - { text: textOnPostgreSQL(checkpointMoved), values: ['2', 'a', '1'] }, -]; - -function answeringNothingTo(text: string): (asked: string) => readonly unknown[] { - return (asked) => (asked === text ? [] : [{ answered: asked }]); -} - -describe('the host database on PostgreSQL, writing in one transaction', () => { - it('runs the statements in order on one connection and keeps them, answering what the last answers, when the last writes a row', async () => { - const { store, connections, asked, said } = telling(); - const database = await postgresqlDatabaseOn(store, connections, []); - const migration = asked().length; - - const written = await Effect.runPromise(database.transaction([runsMoved, checkpointMoved])); - - expect(asked().slice(migration)).toEqual([...transactionAsked, { text: 'COMMIT', values: [] }]); - expect([written, said().at(-1)]).toEqual([ - [{ answered: textOnPostgreSQL(checkpointMoved) }], - 'connection released', - ]); - }); - - it('keeps none of them when the last writes no row, as when another wrote the row first', async () => { - const { store, connections, asked } = telling('', answeringNothingTo(textOnPostgreSQL(checkpointMoved))); - const database = await postgresqlDatabaseOn(store, connections, []); - const migration = asked().length; - - const written = await Effect.runPromise(database.transaction([runsMoved, checkpointMoved])); - - expect(asked().slice(migration)).toEqual([...transactionAsked, { text: 'ROLLBACK', values: [] }]); - expect(written).toEqual([]); - }); - - it('keeps none of them, and fails with the error of the driver, when one fails midway', async () => { - const { store, connections, asked, said } = telling(textOnPostgreSQL(checkpointMoved)); - const database = await postgresqlDatabaseOn(store, connections, []); - const migration = asked().length; - - const failure = await Effect.runPromise(Effect.flip(database.transaction([runsMoved, checkpointMoved]))); - - expect(asked().slice(migration)).toEqual([...transactionAsked, { text: 'ROLLBACK', values: [] }]); - expect([failure.detail, said().at(-1)]).toEqual([ - `Error: ${textOnPostgreSQL(checkpointMoved)} failed`, - 'connection released', - ]); - }); -}); - -describe('the host database on PostgreSQL, telling the time', () => { - it('reads the clock of the database, which every server on it shares, in milliseconds', async () => { - const { store, connections, asked } = telling('', () => [{ now: '1790845200000' }]); - const database = await postgresqlDatabaseOn(store, connections, []); - const migration = asked().length; - - const now = await Effect.runPromise(clockOf(database)); - - expect(now).toBe(1_790_845_200_000); - expect(asked().slice(migration)).toEqual([ - { text: 'SELECT (EXTRACT(EPOCH FROM now()) * 1000)::bigint AS now', values: [] }, - ]); - }); -}); - -const snapshotBegun = 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'; - -const firstRead = statement`SELECT checkpoint FROM folder_rows WHERE playbook_key = ${'a'}`; - -const secondRead = statement`SELECT COUNT(*) AS rows FROM folder_rows`; - -function bothRead(database: HostDatabase) { - return database.readConsistently((read) => Effect.all([read(firstRead), read(secondRead)])); -} - -describe('the host database on PostgreSQL, reading in one snapshot', () => { - it('reads on one connection in a transaction that only reads and sees one snapshot, and ends it keeping nothing', async () => { - const { store, connections, asked, said } = telling(); - const database = await postgresqlDatabaseOn(store, connections, []); - const migration = asked().length; - - const read = await Effect.runPromise(bothRead(database)); - - expect(asked().slice(migration)).toEqual([ - { text: snapshotBegun, values: [] }, - { text: textOnPostgreSQL(firstRead), values: ['a'] }, - { text: textOnPostgreSQL(secondRead), values: [] }, - { text: 'ROLLBACK', values: [] }, - ]); - expect([read, said().at(-1)]).toEqual([ - [[{ answered: textOnPostgreSQL(firstRead) }], [{ answered: textOnPostgreSQL(secondRead) }]], - 'connection released', - ]); - }); - - it('fails with the error of the driver when a read fails, and still ends the snapshot', async () => { - const { store, connections, asked, said } = telling(textOnPostgreSQL(secondRead)); - const database = await postgresqlDatabaseOn(store, connections, []); - - const failure = await Effect.runPromise(Effect.flip(bothRead(database))); - - expect([failure.detail, asked().at(-1), said().at(-1)]).toEqual([ - `Error: ${textOnPostgreSQL(secondRead)} failed`, - { text: 'ROLLBACK', values: [] }, - 'connection released', - ]); - }); - - it.each([ - ['cannot begin', snapshotBegun], - ['cannot be ended', 'ROLLBACK'], - ])('fails, and gives its connection back, when its snapshot %s', async (_when, failingOn) => { - const { store, connections, said } = telling(failingOn); - const database = await postgresqlDatabaseOn(store, connections, []); - - const failure = await Effect.runPromise(Effect.flip(bothRead(database))); - - expect([failure.detail, said().at(-1)]).toEqual([`Error: ${failingOn} failed`, 'connection released']); - }); -}); diff --git a/packages/workflow-host/src/database/postgresql-database.ts b/packages/workflow-host/src/database/postgresql-database.ts deleted file mode 100644 index aa0eba52f..000000000 --- a/packages/workflow-host/src/database/postgresql-database.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { AppendObserver, EventStore } from '@on.auto/ledger'; -import { postgresqlEventStore, type PostgreSQLPools } from '@on.auto/ledger/postgresql'; -import { Effect, Function, Schema } from 'effect'; - -import { - failedWith, - lastAnswerOf, - oneRowOf, - WholeNumber, - wroteARow, - type HostDatabase, - type ReadStatement, - type StatementAnswer, -} from './host-database.ts'; -import { addedColumns, hostTables, indexesOfAddedColumns } from './host-tables.ts'; -import { statement, textOnPostgreSQL, type Statement, type StatementValue } from './statement.ts'; - -export interface PostgreSQLDatabaseOptions { - readonly pools: PostgreSQLPools; - readonly reportLostConnection: (error: Readonly) => void; - readonly tables: readonly Statement[]; - readonly observer?: AppendObserver | undefined; -} - -export interface Connection { - query(text: string, values?: readonly StatementValue[]): Promise; - release(): void; -} - -export interface Connections { - query(text: string, values: readonly StatementValue[]): Promise; - connect(): Promise; - end(): Promise; -} - -const connectionsForTheHostTables = 4; - -const migrationLock = 7_461_239_041; - -const NowRow = Schema.Struct({ now: WholeNumber }); - -function alwaysKept(): boolean { - return true; -} - -async function inTransaction( - connections: Connections, - statements: readonly Statement[], - isKept: (last: StatementAnswer) => boolean, -): Promise { - const connection = await connections.connect(); - try { - await connection.query('BEGIN'); - const last = await lastAnswerOf(statements, (asked) => connection.query(textOnPostgreSQL(asked), asked.values)); - await connection.query(isKept(last) ? 'COMMIT' : 'ROLLBACK'); - return last; - } catch (failure) { - await connection.query('ROLLBACK'); - throw failure; - } finally { - connection.release(); - } -} - -async function snapshotOn(connections: Connections): Promise { - const connection = await connections.connect(); - try { - await connection.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - return connection; - } catch (failure) { - connection.release(); - throw failure; - } -} - -async function snapshotEnded(connection: Connection): Promise { - try { - await connection.query('ROLLBACK'); - } finally { - connection.release(); - } -} - -function readsOn(connection: Connection): ReadStatement { - return (asked) => - Effect.tryPromise({ - try: () => connection.query(textOnPostgreSQL(asked), asked.values), - catch: failedWith, - }).pipe(Effect.map(({ rows }) => rows)); -} - -async function migratedOn(connections: Connections, tables: readonly Statement[]): Promise { - await inTransaction( - connections, - [ - statement`SELECT pg_advisory_xact_lock(${migrationLock})`, - ...hostTables, - ...tables, - ...addedColumns.map(({ addedWhenMissing }) => addedWhenMissing), - ...indexesOfAddedColumns, - ], - alwaysKept, - ); -} - -export async function postgresqlDatabaseOn( - store: EventStore, - connections: Connections, - tables: readonly Statement[], -): Promise { - const query = (asked: Statement) => - Effect.tryPromise({ - try: () => connections.query(textOnPostgreSQL(asked), asked.values), - catch: failedWith, - }).pipe(Effect.map(({ rows }) => rows)); - const transaction = (statements: readonly Statement[]) => - Effect.tryPromise({ - try: () => inTransaction(connections, statements, wroteARow), - catch: failedWith, - }).pipe(Effect.map(({ rows }) => rows)); - const readConsistently: HostDatabase['readConsistently'] = (reads) => - Effect.acquireUseRelease( - Effect.tryPromise({ try: () => snapshotOn(connections), catch: failedWith }), - (connection) => reads(readsOn(connection)), - (connection) => Effect.tryPromise({ try: () => snapshotEnded(connection), catch: failedWith }), - ); - const sharedClock = oneRowOf(NowRow, query(statement`SELECT (EXTRACT(EPOCH FROM now()) * 1000)::bigint AS now`)).pipe( - Effect.map(({ now }) => now), - ); - const close = async (): Promise => { - await store.close(); - await connections.end(); - }; - try { - await store.migrate(); - await migratedOn(connections, tables); - return { store, read: query, write: query, transaction, readConsistently, sharedClock, close }; - } catch (failure) { - await close().catch(Function.constVoid); - throw failure; - } -} - -export function openPostgreSQLDatabase({ - pools, - reportLostConnection, - tables, - observer, -}: PostgreSQLDatabaseOptions): Promise { - const pool = pools({ max: connectionsForTheHostTables }); - pool.on('error', reportLostConnection); - return postgresqlDatabaseOn(postgresqlEventStore({ pools, reportLostConnection, observer }), pool, tables); -} diff --git a/packages/workflow-host/src/database/sqlite-database.test.ts b/packages/workflow-host/src/database/sqlite-database.test.ts index db907d7ab..b8e3f4ddd 100644 --- a/packages/workflow-host/src/database/sqlite-database.test.ts +++ b/packages/workflow-host/src/database/sqlite-database.test.ts @@ -1,11 +1,10 @@ import { mkdirSync } from 'node:fs'; +import { statement } from '@on.auto/ledger'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; -import { openSQLiteDatabase } from './sqlite-database.ts'; -import { statement } from './statement.ts'; const tablesOfTheHost = statement`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'workflow_%' ORDER BY name`; @@ -110,6 +109,6 @@ describe('the host database on SQLite, in memory or refusing', () => { const file = aSQLiteFile(); mkdirSync(file); - await expect(openSQLiteDatabase(file, [])).rejects.toThrow('SQLITE_CANTOPEN'); + await expect(openedOn({ store: 'sqlite', file })).rejects.toThrow('SQLITE_CANTOPEN'); }); }); diff --git a/packages/workflow-host/src/database/sqlite-database.ts b/packages/workflow-host/src/database/sqlite-database.ts deleted file mode 100644 index fa79df2bf..000000000 --- a/packages/workflow-host/src/database/sqlite-database.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { mkdirSync } from 'node:fs'; -import { dirname } from 'node:path'; - -import { dumbo, type SQL } from '@event-driven-io/dumbo'; -import { sqlite3EventStoreDriver } from '@event-driven-io/emmett-sqlite/sqlite3'; -import { sqliteEventStore, type AppendObserver } from '@on.auto/ledger'; -import { Effect, Function, Schema } from 'effect'; - -import { - failedWith, - lastAnswerOf, - wroteARow, - type DatabaseFailed, - type HostDatabase, - type ReadStatement, - type StatementAnswer, -} from './host-database.ts'; -import { addedColumns, columnsOnSQLite, hostTables, indexesOfAddedColumns, type AddedColumn } from './host-tables.ts'; -import { onSQLite, type Statement } from './statement.ts'; - -const pageCacheOfEightMebibytes = -8192; - -const modestConnections = { pragmaOptions: { cache_size: pageCacheOfEightMebibytes, mmap_size: 0 } }; - -const privateMemory = ':memory:'; - -function prepareDirectoryOf(fileName: string): void { - if (fileName !== privateMemory) { - mkdirSync(dirname(fileName), { recursive: true }); - } -} - -const ColumnRows = Schema.Array(Schema.Struct({ name: Schema.String })); - -interface WriterTransaction { - readonly execute: { readonly command: (sql: SQL) => Promise }; -} - -interface ReaderTransaction { - readonly execute: { readonly query: (sql: SQL) => Promise }; -} - -type ReadingTransaction = (transaction: ReaderTransaction) => Promise<{ success: boolean; result: A }>; - -type InASnapshot = (handle: ReadingTransaction) => Promise; - -interface SnapshotReader { - readonly withTransaction: (handle: ReadingTransaction, options: typeof deferred) => Promise; -} - -const deferred = { mode: 'DEFERRED' } as const; - -function readsOn({ execute }: ReaderTransaction): ReadStatement { - return (statement) => - Effect.tryPromise({ - try: async (): Promise => (await execute.query(onSQLite(statement))).rows, - catch: failedWith, - }); -} - -function consistentReadsIn(inASnapshot: InASnapshot): HostDatabase['readConsistently'] { - return (reads) => - Effect.flatMap(Effect.context(), (context) => - Effect.flatten( - Effect.tryPromise({ - try: (signal) => - inASnapshot(async (transaction) => ({ - success: true, - result: await Effect.runPromiseExitWith(context)(reads(readsOn(transaction)), { signal }), - })), - catch: failedWith, - }), - ), - ); -} - -function columnAddedTo( - database: HostDatabase, - { table, column, added }: AddedColumn, -): Effect.Effect { - return Effect.gen(function* () { - const columns = Schema.decodeUnknownSync(ColumnRows)(yield* database.read(columnsOnSQLite(table))); - if (!columns.some(({ name }) => name === column)) { - yield* database.write(added); - } - }); -} - -function columnsAddedTo(database: HostDatabase): Effect.Effect { - return Effect.andThen( - Effect.forEach(addedColumns, (column) => columnAddedTo(database, column), { discard: true }), - Effect.forEach(indexesOfAddedColumns, database.write, { discard: true }), - ); -} - -export async function openSQLiteDatabase( - fileName: string, - tables: readonly Statement[], - observer?: AppendObserver, -): Promise { - prepareDirectoryOf(fileName); - const options = { driver: sqlite3EventStoreDriver, fileName, connectionOptions: modestConnections }; - const pool = dumbo(sqlite3EventStoreDriver.mapToDumboOptions(options)); - const store = sqliteEventStore(() => ({ ...options, pool }), {}, observer); - const inASnapshot: InASnapshot = - fileName === privateMemory - ? (handle) => pool.withTransaction(handle, deferred) - : (handle) => - pool.withConnection((reader: SnapshotReader) => reader.withTransaction(handle, deferred), { readonly: true }); - const database: HostDatabase = { - store, - read: (statement) => - Effect.tryPromise({ - try: async (): Promise => (await pool.execute.query(onSQLite(statement))).rows, - catch: failedWith, - }), - write: (statement) => - Effect.tryPromise({ - try: async (): Promise => (await pool.execute.command(onSQLite(statement))).rows, - catch: failedWith, - }), - transaction: (statements) => - Effect.tryPromise({ - try: () => - pool.withTransaction(async ({ execute }: WriterTransaction) => { - const last = await lastAnswerOf(statements, (statement) => execute.command(onSQLite(statement))); - return { success: wroteARow(last), result: last.rows }; - }), - catch: failedWith, - }), - readConsistently: consistentReadsIn(inASnapshot), - sharedClock: null, - close: () => store.close(), - }; - try { - await store.migrate(); - await Effect.runPromise(Effect.forEach([...hostTables, ...tables], database.write, { discard: true })); - await Effect.runPromise(columnsAddedTo(database)); - return database; - } catch (failure) { - await store.close().catch(Function.constVoid); - throw failure; - } -} diff --git a/packages/workflow-host/src/database/store-modules.test.ts b/packages/workflow-host/src/database/store-modules.test.ts index 17950804b..6e78b8205 100644 --- a/packages/workflow-host/src/database/store-modules.test.ts +++ b/packages/workflow-host/src/database/store-modules.test.ts @@ -18,9 +18,11 @@ function driversReachedFrom(module: string): readonly string[] { } describe('the module of the database of each store', () => { - it('is reached from neither entry of the host, and loads when the host first opens a database on its store', () => { - expect([...driversReachedFrom('index.ts'), ...driversReachedFrom('testing/index.ts')]).toEqual([]); - expect(driversReachedFrom('database/sqlite-database.ts')).toEqual(['@event-driven-io/emmett-sqlite/sqlite3']); - expect(driversReachedFrom('database/postgresql-database.ts')).toEqual(['@on.auto/ledger/postgresql']); + it('is reached from neither entry of the host, nor from the opening of a ledger for the tests, which loads it when it opens one', () => { + expect([ + ...driversReachedFrom('index.ts'), + ...driversReachedFrom('testing/index.ts'), + ...driversReachedFrom('ledger-testing/ledger-connections.ts'), + ]).toEqual([]); }); }); diff --git a/packages/workflow-host/src/dispatch/sql-watermark.ts b/packages/workflow-host/src/dispatch/sql-watermark.ts index 6db6d6383..c3c1c8080 100644 --- a/packages/workflow-host/src/dispatch/sql-watermark.ts +++ b/packages/workflow-host/src/dispatch/sql-watermark.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import type { DispatchWatermark } from '@on.auto/workflow-engine'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { runCaughtUp } from '../listeners/listener-rows.ts'; import { runLogStreamOf } from '../runs/run-address.ts'; diff --git a/packages/workflow-host/src/due-work/due-chores.test.ts b/packages/workflow-host/src/due-work/due-chores.test.ts new file mode 100644 index 000000000..6eb747fbd --- /dev/null +++ b/packages/workflow-host/src/due-work/due-chores.test.ts @@ -0,0 +1,77 @@ +import type { WorkflowEngine } from '@on.auto/workflow-engine'; +import { Effect, Function } from 'effect'; +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { hostWakeUp, type NextWake } from '../loop/host-wake-up.ts'; +import { until } from '../testing/until.ts'; +import { steppedClock, type SteppedClock } from '../views-testing/stepped-sweeps.ts'; +import { choreWork, type Chore } from './due-chores.ts'; + +const noTimers = { + due: () => Effect.succeed([]), + nextDueAt: () => Effect.succeed(null), + fired: () => Effect.void, + postponed: () => Effect.void, +}; + +const idleEngine: WorkflowEngine = { + submit: () => Effect.die(new Error('A chore submits nothing')), + wake: () => Effect.die(new Error('A chore wakes no run')), + sweep: () => Effect.succeed({ runs: 0, timersArmedAgain: 0 }), +}; + +function wakeUpWith(chore: Chore, clock: SteppedClock): (now: number) => Promise { + const work = hostWakeUp( + { + clock, + timers: noTimers, + dueWork: [choreWork(chore)], + engine: idleEngine, + fire: () => Effect.void, + resume: () => Effect.succeed(0), + trouble: () => Effect.void, + sweepEveryMs: 1000, + }, + { raised: Function.constVoid, renewed: Function.constVoid }, + ); + onTestFinished(() => Effect.runPromise(work.stop())); + return (now) => Effect.runPromise(work.wakeUp({ now, sweep: false })); +} + +describe('a chore the wake-up of the host performs', () => { + it('is performed at the first wake-up, wakes nothing while it is under way, then waits for the time it answered', async () => { + const performedAt: number[] = []; + const held = Promise.withResolvers(); + const clock = steppedClock(5000); + const chore: Chore = { + name: 'orphans', + perform: (now) => + Effect.andThen( + Effect.promise(() => held.promise), + Effect.sync(() => { + performedAt.push(now); + return now + 1000; + }), + ), + }; + const wakeUp = wakeUpWith(chore, clock); + + const underWay = await wakeUp(5000); + held.resolve(); + await until( + () => performedAt.length, + (performed) => performed > 0, + ); + clock.step(500); + const between = await wakeUp(5500); + clock.step(500); + await wakeUp(6000); + await until( + () => performedAt.length, + (performed) => performed > 1, + ); + + expect([underWay, between]).toEqual([{ nextDueAt: null }, { nextDueAt: 6000 }]); + expect(performedAt).toEqual([5000, 6000]); + }); +}); diff --git a/packages/workflow-host/src/due-work/due-chores.ts b/packages/workflow-host/src/due-work/due-chores.ts new file mode 100644 index 000000000..f79fcf010 --- /dev/null +++ b/packages/workflow-host/src/due-work/due-chores.ts @@ -0,0 +1,22 @@ +import { Effect } from 'effect'; + +import type { DueWork } from './due-work.ts'; + +export interface Chore { + readonly name: string; + readonly perform: (now: number) => Effect.Effect; +} + +export function choreWork({ name, perform }: Chore): DueWork { + const due = { at: Number.NEGATIVE_INFINITY }; + const performed = (now: number) => + Effect.map(perform(now), (next) => { + due.at = next; + }); + return { + name, + due: (now, most, callsOut) => + Effect.succeed((callsOut && due.at <= now ? [{ key: name, callsOut, perform: performed }] : []).slice(0, most)), + nextDueAt: (after) => Effect.succeed(due.at > after ? due.at : null), + }; +} diff --git a/packages/workflow-host/src/folder-testing/projected-folders.ts b/packages/workflow-host/src/folder-testing/projected-folders.ts index e188bc45e..352965193 100644 --- a/packages/workflow-host/src/folder-testing/projected-folders.ts +++ b/packages/workflow-host/src/folder-testing/projected-folders.ts @@ -2,9 +2,9 @@ import { appendSignal, type EncodedEvent } from '@on.auto/ledger'; import type { Context } from '@on.auto/operations'; import type { HostDatabase } from '../database/host-database.ts'; -import type { DatabaseSettings } from '../database/host-databases.ts'; import type { HostNote, HostReports } from '../host/host-reports.ts'; import { openWorkflowStore } from '../host/workflow-store.ts'; +import { ledgerConnectedOn, type DatabaseSettings } from '../ledger-testing/ledger-connections.ts'; import { systemClock } from '../loop/host-clock.ts'; import type { SomeFolder } from '../projector/folder.ts'; import { startProjector, type Projector } from '../projector/projector.ts'; @@ -38,7 +38,8 @@ export async function projectedFolders( ): Promise { const reports = recordingReports(); const appends = appendSignal(); - const { database } = await openWorkflowStore(settings, reports.reports.lostConnection, folders); + const ledger = await ledgerConnectedOn(settings); + const { database } = await openWorkflowStore(ledger.connection, folders); const versions = new Map(); const running: Projector[] = []; const stop = async (): Promise => { @@ -69,7 +70,7 @@ export async function projectedFolders( stop, close: async () => { await stop(); - await database.close(); + await ledger.close(); }, }; } diff --git a/packages/workflow-host/src/folder-testing/tally-folder.ts b/packages/workflow-host/src/folder-testing/tally-folder.ts index 9ec7def73..810bf9d9f 100644 --- a/packages/workflow-host/src/folder-testing/tally-folder.ts +++ b/packages/workflow-host/src/folder-testing/tally-folder.ts @@ -1,7 +1,7 @@ +import { type Statement, statement } from '@on.auto/ledger'; import { Effect, Option, Schema } from 'effect'; import { rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement, type Statement } from '../database/statement.ts'; import { comparePoints, isAfter, pointIn, pointText, type Point } from '../pages/history-points.ts'; import type { PageEvent, ReadPage } from '../pages/page-events.ts'; import type { FoldedChanges, Folder, Reconciled, Reconciling, RowPhase, Writing } from '../projector/folder.ts'; diff --git a/packages/workflow-host/src/folder-testing/tally-harness.ts b/packages/workflow-host/src/folder-testing/tally-harness.ts index 7cb19c0a1..1a8e12aa9 100644 --- a/packages/workflow-host/src/folder-testing/tally-harness.ts +++ b/packages/workflow-host/src/folder-testing/tally-harness.ts @@ -1,6 +1,6 @@ import { Effect, type Schema } from 'effect'; -import type { DatabaseSettings } from '../database/host-databases.ts'; +import type { DatabaseSettings } from '../ledger-testing/ledger-connections.ts'; import { someFolder } from '../projector/folder.ts'; import { definitionStreamOf } from '../projector/playbook-definitions.ts'; import type { Projector } from '../projector/projector.ts'; diff --git a/packages/workflow-host/src/follower/followed-playbooks.ts b/packages/workflow-host/src/follower/followed-playbooks.ts index ef9353539..ebea4b8df 100644 --- a/packages/workflow-host/src/follower/followed-playbooks.ts +++ b/packages/workflow-host/src/follower/followed-playbooks.ts @@ -1,7 +1,7 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; export interface Progress { readonly cursor: string | null; diff --git a/packages/workflow-host/src/follower/follower-guards.test.ts b/packages/workflow-host/src/follower/follower-guards.test.ts index c0f1880e2..a9ac4e55e 100644 --- a/packages/workflow-host/src/follower/follower-guards.test.ts +++ b/packages/workflow-host/src/follower/follower-guards.test.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { rowsOf } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { alpha, definitionRecorded, diff --git a/packages/workflow-host/src/follower/follower-listeners.test.ts b/packages/workflow-host/src/follower/follower-listeners.test.ts index b61772ac7..489bcb8ff 100644 --- a/packages/workflow-host/src/follower/follower-listeners.test.ts +++ b/packages/workflow-host/src/follower/follower-listeners.test.ts @@ -1,7 +1,7 @@ +import { statement } from '@on.auto/ledger'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { statement } from '../database/statement.ts'; import { emittedContext, published, publishedInTurn } from '../reaction-testing/playbook-writes.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; diff --git a/packages/workflow-host/src/follower/follower-subscriptions.test.ts b/packages/workflow-host/src/follower/follower-subscriptions.test.ts index 5a5af8b7a..33e34b406 100644 --- a/packages/workflow-host/src/follower/follower-subscriptions.test.ts +++ b/packages/workflow-host/src/follower/follower-subscriptions.test.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import { messageIdOf } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { rowsOf } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { alpha, at, diff --git a/packages/workflow-host/src/follower/listener-scan.ts b/packages/workflow-host/src/follower/listener-scan.ts index 4fcda14f0..aa998276a 100644 --- a/packages/workflow-host/src/follower/listener-scan.ts +++ b/packages/workflow-host/src/follower/listener-scan.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import { streamPrefixOfPlaybook } from '@on.auto/operations'; import { listenFiltersOf, loadedRunOf, valueAtPointer, type CallKey } from '@on.auto/workflow-engine'; import { Effect, Schema } from 'effect'; import { rowsOf, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { insertedListener } from '../listeners/listener-rows.ts'; import { reactionOfRun } from '../reactions/run-attributes.ts'; import { ledgerRunLogStore } from '../runs/ledger-run-store.ts'; diff --git a/packages/workflow-host/src/follower/playbook-discovery.test.ts b/packages/workflow-host/src/follower/playbook-discovery.test.ts index 39f7e1e64..73c984993 100644 --- a/packages/workflow-host/src/follower/playbook-discovery.test.ts +++ b/packages/workflow-host/src/follower/playbook-discovery.test.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import { messageIdOf } from '@on.auto/operations'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { statement } from '../database/statement.ts'; import { alpha, playbookCreated, diff --git a/packages/workflow-host/src/follower/playbook-discovery.ts b/packages/workflow-host/src/follower/playbook-discovery.ts index 5186efc11..118ee5df4 100644 --- a/packages/workflow-host/src/follower/playbook-discovery.ts +++ b/packages/workflow-host/src/follower/playbook-discovery.ts @@ -1,10 +1,9 @@ -import { recordedCursorOf, type RecordedPoint } from '@on.auto/ledger'; +import { recordedCursorOf, type RecordedPoint, statement } from '@on.auto/ledger'; import { streamPrefixOfPlaybook, type PlaybookAddress } from '@on.auto/operations'; import { playbookCreatedOf, playbooksStreamOfOrg } from '@on.auto/playbooks'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { definitionRecordsIn, type ApplyDefinitionRecord, diff --git a/packages/workflow-host/src/follower/playbook-records.ts b/packages/workflow-host/src/follower/playbook-records.ts index 8eade2dab..e7d0aa5ef 100644 --- a/packages/workflow-host/src/follower/playbook-records.ts +++ b/packages/workflow-host/src/follower/playbook-records.ts @@ -1,4 +1,4 @@ -import { recordedReaderOf, type EventStore } from '@on.auto/ledger'; +import { recordedReaderOf, type LedgerStreams } from '@on.auto/ledger'; import type { PlaybookAddress, RecordedEvent, RecordedPage } from '@on.auto/operations'; import { Effect } from 'effect'; @@ -33,7 +33,7 @@ export function relativeRecord(playbookKey: string, record: RecordedEvent): Reco return { ...record, stream: record.stream.slice(playbookKey.length) }; } -export function playbookRecordsOf(store: EventStore): PlaybookRecords { +export function playbookRecordsOf(store: LedgerStreams): PlaybookRecords { const read = recordedReaderOf(store); return { after: (playbook, cursor, delivers) => diff --git a/packages/workflow-host/src/follower/run-gate.test.ts b/packages/workflow-host/src/follower/run-gate.test.ts index b6d4c62b2..7ef3e8fd3 100644 --- a/packages/workflow-host/src/follower/run-gate.test.ts +++ b/packages/workflow-host/src/follower/run-gate.test.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import type { RecordedEvent } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { sqlWatermark } from '../dispatch/sql-watermark.ts'; import { insertedListener } from '../listeners/listener-rows.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; diff --git a/packages/workflow-host/src/host/host-parts.test.ts b/packages/workflow-host/src/host/host-parts.test.ts index d15c59fe2..a15e20cac 100644 --- a/packages/workflow-host/src/host/host-parts.test.ts +++ b/packages/workflow-host/src/host/host-parts.test.ts @@ -1,6 +1,6 @@ import { defaultLimits } from '@on.auto/workflow-engine/testing'; import { Effect, Function } from 'effect'; -import { describe, expect, it, onTestFinished } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { closeHostParts, @@ -14,7 +14,7 @@ import { } from '../index.ts'; import { runKeyOf } from '../runs/run-address.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; -import { aSQLiteFile, leaseHoldersOn } from '../testing/host-files.ts'; +import { aSQLiteFile, connectedOn, leaseHoldersOn } from '../testing/host-files.ts'; import { recordedOptionsOf } from '../testing/host-runs.ts'; import { watchedClock } from '../testing/watched-clock.ts'; import { steppedClock } from '../views-testing/stepped-sweeps.ts'; @@ -34,8 +34,7 @@ interface OwnClocked { } async function ownClocked(clock: HostClock): Promise { - const store = await openWorkflowStore({ store: 'sqlite', file: aSQLiteFile() }, Function.constVoid, []); - onTestFinished(() => store.database.close()); + const store = await openWorkflowStore(await connectedOn({ store: 'sqlite', file: aSQLiteFile() }), []); const recorded = recordedOptionsOf({ sweepEveryMs: 1000 }); const armed: number[] = []; const parts = openHostParts(store, { ...recorded.options, clock }, (dueAt) => { diff --git a/packages/workflow-host/src/host/host-reports.ts b/packages/workflow-host/src/host/host-reports.ts index ab29b1d15..c169a3d15 100644 --- a/packages/workflow-host/src/host/host-reports.ts +++ b/packages/workflow-host/src/host/host-reports.ts @@ -53,6 +53,5 @@ export interface UnsettledRun extends RunAddress { export interface HostReports { readonly unsettled: (run: UnsettledRun) => Effect.Effect; readonly trouble: Trouble; - readonly lostConnection: (error: Readonly) => void; readonly note: (note: HostNote) => Effect.Effect; } diff --git a/packages/workflow-host/src/host/host-stopping-reactions.test.ts b/packages/workflow-host/src/host/host-stopping-reactions.test.ts index 5188e4ea7..890cb8f35 100644 --- a/packages/workflow-host/src/host/host-stopping-reactions.test.ts +++ b/packages/workflow-host/src/host/host-stopping-reactions.test.ts @@ -2,7 +2,7 @@ import { streamAppends, type StreamSignal } from '@on.auto/ledger'; import { Effect, Exit } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { DatabaseSettings } from '../database/host-databases.ts'; +import type { DatabaseSettings } from '../ledger-testing/ledger-connections.ts'; import { heldStart, type HeldStart } from '../reaction-testing/held-start.ts'; import { eventTrigger, published, definitionRecorded } from '../reaction-testing/playbook-writes.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; diff --git a/packages/workflow-host/src/host/host-views.test.ts b/packages/workflow-host/src/host/host-views.test.ts index 7d524cec7..e6237d462 100644 --- a/packages/workflow-host/src/host/host-views.test.ts +++ b/packages/workflow-host/src/host/host-views.test.ts @@ -1,9 +1,9 @@ -import { Effect, Function } from 'effect'; +import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { tallyFolderOf } from '../folder-testing/tally-folder.ts'; import { someFolder } from '../projector/folder.ts'; -import { onSQLite, openedOn } from '../testing/host-files.ts'; +import { connectedOn, onSQLite, openedOn } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; import { watchedSystemClock } from '../testing/watched-clock.ts'; import { counting } from '../views-testing/view-documents.ts'; @@ -17,7 +17,7 @@ describe('a host given a store and the settings of its projector', () => { await views.saved('runs', counting); await views.ran('reasoning/runs', 1); const projector = views.settingsOf(); - const store = await openWorkflowStore(settings, Function.constVoid, projector.folders); + const store = await openWorkflowStore(await connectedOn(settings), projector.folders); const clock = watchedSystemClock(); const hosted = await hostedOn(store, { projector, clock }); diff --git a/packages/workflow-host/src/host/run-requests.ts b/packages/workflow-host/src/host/run-requests.ts index 57ec0403a..90e6b8dbf 100644 --- a/packages/workflow-host/src/host/run-requests.ts +++ b/packages/workflow-host/src/host/run-requests.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import type { Conflict } from '@on.auto/operations'; import { loadedRunOf, type ReceivedEvent, type RunState, type Started } from '@on.auto/workflow-engine'; import { Data, Effect, Schema } from 'effect'; import { rowsOf, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { ledgerRunLogStore } from '../runs/ledger-run-store.ts'; import { runKeyOf, type RunAddress } from '../runs/run-address.ts'; import { backOffLifted } from '../settlement/settle-attempts.ts'; diff --git a/packages/workflow-host/src/host/workflow-host.ts b/packages/workflow-host/src/host/workflow-host.ts index 544b4cecd..40caa2d27 100644 --- a/packages/workflow-host/src/host/workflow-host.ts +++ b/packages/workflow-host/src/host/workflow-host.ts @@ -33,7 +33,7 @@ export interface WorkflowHost { export async function openWorkflowHost(options: HostOptions): Promise { const clock = options.clock ?? systemClock; - const store = await storeOf(options.database, options.reports.lostConnection, options.projector?.folders ?? []); + const store = await storeOf(options.database, options.projector?.folders ?? []); const { through: placeAtOpen } = await store.database.store.readAppended(undefined, 0); const standing = await standingOn(store, { ...options, clock, placeAtOpen }, options.holder); const { guarded, closed } = gate(); @@ -42,7 +42,6 @@ export async function openWorkflowHost(options: HostOptions): Promise } = {}; return { diff --git a/packages/workflow-host/src/host/workflow-store.ts b/packages/workflow-host/src/host/workflow-store.ts index 4bb1cca52..0657ec1b0 100644 --- a/packages/workflow-host/src/host/workflow-store.ts +++ b/packages/workflow-host/src/host/workflow-store.ts @@ -1,5 +1,7 @@ -import type { HostDatabase } from '../database/host-database.ts'; -import { openHostDatabase, type DatabaseSettings } from '../database/host-databases.ts'; +import type { ConnectionPorts } from '@on.auto/ledger'; + +import { hostDatabaseOn, type HostDatabase } from '../database/host-database.ts'; +import { hostTablesMadeOn } from '../database/host-tables.ts'; import { tablesOf, type SomeFolder } from '../projector/folder.ts'; export interface WorkflowStore { @@ -7,26 +9,19 @@ export interface WorkflowStore { } export async function openWorkflowStore( - settings: DatabaseSettings, - reportLostConnection: (error: Readonly) => void, + connection: ConnectionPorts, folders: readonly SomeFolder[], ): Promise { - const database = await openHostDatabase(settings, reportLostConnection, tablesOf(folders)); - return { database }; + await hostTablesMadeOn(connection, tablesOf(folders)); + return { database: hostDatabaseOn(connection) }; } -export type StoreSettings = DatabaseSettings | WorkflowStore; +export type StoreSettings = ConnectionPorts | WorkflowStore; function isWorkflowStore(database: StoreSettings): database is WorkflowStore { return 'database' in database; } -export function storeOf( - database: StoreSettings, - reportLostConnection: (error: Readonly) => void, - folders: readonly SomeFolder[], -): Promise { - return isWorkflowStore(database) - ? Promise.resolve(database) - : openWorkflowStore(database, reportLostConnection, folders); +export function storeOf(database: StoreSettings, folders: readonly SomeFolder[]): Promise { + return isWorkflowStore(database) ? Promise.resolve(database) : openWorkflowStore(database, folders); } diff --git a/packages/workflow-host/src/index.ts b/packages/workflow-host/src/index.ts index 9f09c22ac..3e8658340 100644 --- a/packages/workflow-host/src/index.ts +++ b/packages/workflow-host/src/index.ts @@ -1,5 +1,5 @@ export type { CallAnswer, Perform, Trouble } from './calls/host-executor.ts'; -export type { DatabaseSettings } from './database/host-databases.ts'; +export { choreWork, type Chore } from './due-work/due-chores.ts'; export { dueInOneWakeUp, duePerformedAtOnce, type DueItem, type DueWork } from './due-work/due-work.ts'; export { rowWaitBounds } from './due-work/row-waits.ts'; export { DeliveryFailed, type Consumer, type Delivery, type FollowedRecord } from './follower/consumers.ts'; @@ -17,7 +17,7 @@ export { type HostDatabase, type ReadStatement, } from './database/host-database.ts'; -export { statement, type Statement, type StatementValue } from './database/statement.ts'; +export { statement, type Statement, type StatementValue } from '@on.auto/ledger'; export type { PageEvent, ReadPage } from './pages/page-events.ts'; export type { PlaybookDefinitions, KeptFunction, KeptOf } from './projector/playbook-definitions.ts'; export { diff --git a/packages/workflow-host/src/lease/host-lease.ts b/packages/workflow-host/src/lease/host-lease.ts index 4d846665c..ed6c425ad 100644 --- a/packages/workflow-host/src/lease/host-lease.ts +++ b/packages/workflow-host/src/lease/host-lease.ts @@ -1,7 +1,7 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { oneRowOf, rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import type { HostClock } from '../loop/host-clock.ts'; const sweepsBeforeALeaseLapses = 3; diff --git a/packages/workflow-host/src/ledger-testing/hosted-databases.ts b/packages/workflow-host/src/ledger-testing/hosted-databases.ts new file mode 100644 index 000000000..8ae8fdb2f --- /dev/null +++ b/packages/workflow-host/src/ledger-testing/hosted-databases.ts @@ -0,0 +1,9 @@ +import type { StoreSettings, WorkflowStore } from '../host/workflow-store.ts'; +import { connectedOn } from '../testing/host-files.ts'; +import type { DatabaseSettings } from './ledger-connections.ts'; + +export type HostedDatabase = DatabaseSettings | WorkflowStore; + +export function storeSettingsOf(database: HostedDatabase): Promise { + return 'database' in database ? Promise.resolve(database) : connectedOn(database); +} diff --git a/packages/workflow-host/src/ledger-testing/ledger-connections.test.ts b/packages/workflow-host/src/ledger-testing/ledger-connections.test.ts new file mode 100644 index 000000000..99a753f47 --- /dev/null +++ b/packages/workflow-host/src/ledger-testing/ledger-connections.test.ts @@ -0,0 +1,62 @@ +import { fstatSync, readdirSync, statSync } from 'node:fs'; + +import { statement } from '@on.auto/ledger'; +import { describe, expect, it } from 'vitest'; + +import { tallyFolderOf } from '../folder-testing/tally-folder.ts'; +import { someFolder } from '../projector/folder.ts'; +import { aSQLiteFile } from '../testing/host-files.ts'; +import { recordedOptionsOf } from '../testing/host-runs.ts'; +import { hostDatabaseOpenedOn, workflowHostOnALedger } from './ledger-connections.ts'; + +const unmadeTable = statement`CREATE TABLE`; + +function opensTheFile(descriptor: number, device: number, inode: number): boolean { + try { + const opened = fstatSync(descriptor); + return opened.dev === device && opened.ino === inode; + } catch { + return false; + } +} + +function descriptorsOpenOn(file: string): number { + const { dev, ino } = statSync(file); + return readdirSync('/dev/fd').filter((descriptor) => opensTheFile(Number(descriptor), dev, ino)).length; +} + +describe('a host database opened on a ledger of its own', () => { + it('closes the ledger it opened when the tables asked of it cannot be made', async () => { + const file = aSQLiteFile(); + + await expect(hostDatabaseOpenedOn({ store: 'sqlite', file }, [unmadeTable])).rejects.toThrow('SQLITE_ERROR'); + expect(descriptorsOpenOn(file)).toBe(0); + }); +}); + +describe('a host opened on a ledger of its own', () => { + it('closes the ledger it opened once the host stops', async () => { + const file = aSQLiteFile(); + const host = await workflowHostOnALedger({ store: 'sqlite', file }, recordedOptionsOf().options); + const whileServing = descriptorsOpenOn(file); + + await host.stop(); + + expect({ whileServing: whileServing > 0, afterStopping: descriptorsOpenOn(file) }).toEqual({ + whileServing: true, + afterStopping: 0, + }); + }); + + it('closes the ledger it opened when the host cannot open', async () => { + const file = aSQLiteFile(); + const unmade = someFolder({ ...tallyFolderOf(1), tables: [unmadeTable] }); + const options = { + ...recordedOptionsOf().options, + projector: { folders: [unmade], playbooksAtOnce: 1, pagesPerWake: 1 }, + }; + + await expect(workflowHostOnALedger({ store: 'sqlite', file }, options)).rejects.toThrow('SQLITE_ERROR'); + expect(descriptorsOpenOn(file)).toBe(0); + }); +}); diff --git a/packages/workflow-host/src/ledger-testing/ledger-connections.ts b/packages/workflow-host/src/ledger-testing/ledger-connections.ts new file mode 100644 index 000000000..f9dc38a4b --- /dev/null +++ b/packages/workflow-host/src/ledger-testing/ledger-connections.ts @@ -0,0 +1,80 @@ +import { + LedgerConnection, + type AppendObserver, + type ConnectionPorts, + type LedgerServices, + type Statement, +} from '@on.auto/ledger'; +import type { PostgreSQLPools } from '@on.auto/ledger/postgresql'; +import { Effect, ManagedRuntime, type Layer } from 'effect'; + +import { hostDatabaseOn, type HostDatabase } from '../database/host-database.ts'; +import { hostTablesMadeOn } from '../database/host-tables.ts'; +import { openWorkflowHost, type HostOptions, type WorkflowHost } from '../host/workflow-host.ts'; + +export type DatabaseSettings = + | { readonly store: 'sqlite'; readonly file: string; readonly observer?: AppendObserver | undefined } + | { readonly store: 'postgresql'; readonly pools: PostgreSQLPools; readonly observer?: AppendObserver | undefined }; + +export interface ConnectedLedger { + readonly connection: ConnectionPorts; + readonly close: () => Promise; +} + +export interface ClosingHostDatabase extends HostDatabase { + readonly close: () => Promise; +} + +async function ledgerLayerOn(settings: DatabaseSettings): Promise> { + if (settings.store === 'sqlite') { + const { ledgerLayer } = await import('@on.auto/ledger/sqlite3'); + return ledgerLayer({ fileName: settings.file, observer: settings.observer }); + } + const { postgresqlLedgerLayer } = await import('@on.auto/ledger/postgresql'); + return postgresqlLedgerLayer({ pools: settings.pools, observer: settings.observer }); +} + +export async function ledgerConnectedOn(settings: DatabaseSettings): Promise { + const runtime = ManagedRuntime.make(await ledgerLayerOn(settings)); + try { + const connection = await runtime.runPromise(Effect.service(LedgerConnection)); + return { connection, close: () => runtime.dispose() }; + } catch (failure) { + await runtime.dispose(); + throw failure; + } +} + +export async function hostDatabaseOpenedOn( + settings: DatabaseSettings, + tables: readonly Statement[] = [], +): Promise { + const { connection, close } = await ledgerConnectedOn(settings); + try { + await hostTablesMadeOn(connection, tables); + } catch (failure) { + await close(); + throw failure; + } + return { ...hostDatabaseOn(connection), close }; +} + +export async function workflowHostOnALedger( + settings: DatabaseSettings, + options: Omit, +): Promise { + const { connection, close } = await ledgerConnectedOn(settings); + try { + const host = await openWorkflowHost({ ...options, database: connection }); + return { + ...host, + stop: async () => { + await host.stop(); + await close(); + }, + }; + } catch (failure) { + await close(); + throw failure; + } +} diff --git a/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts b/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts index 2a5d30681..5962932c6 100644 --- a/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts +++ b/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts @@ -1,11 +1,11 @@ import { randomUUID } from 'node:crypto'; +import { statement, textOnPostgreSQL } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { Client } from 'pg'; import { describe, expect, it, onTestFinished } from 'vitest'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement, textOnPostgreSQL } from '../database/statement.ts'; import { sqlWatermark } from '../dispatch/sql-watermark.ts'; import { runGateOf } from '../follower/run-gate.ts'; import { onPostgreSQLAt, openedOn } from '../testing/host-files.ts'; diff --git a/packages/workflow-host/src/listeners/listener-rows.ts b/packages/workflow-host/src/listeners/listener-rows.ts index 4cf4faf02..5bd382acb 100644 --- a/packages/workflow-host/src/listeners/listener-rows.ts +++ b/packages/workflow-host/src/listeners/listener-rows.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import { CallKeySchema, type CallKey } from '@on.auto/workflow-engine'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; export interface ListenerRow { readonly runKey: string; diff --git a/packages/workflow-host/src/listeners/sql-listeners.test.ts b/packages/workflow-host/src/listeners/sql-listeners.test.ts index 23e3ce1bf..8295236c0 100644 --- a/packages/workflow-host/src/listeners/sql-listeners.test.ts +++ b/packages/workflow-host/src/listeners/sql-listeners.test.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import type { ArmListener } from '@on.auto/workflow-engine'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { rowsOf } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { filterStops, type FilterPlace } from '../filtering/filter-matching.ts'; import { refusalsOn } from '../reactions/refusals.ts'; import { faultyDatabase } from '../testing/faulty-database.ts'; diff --git a/packages/workflow-host/src/pages/page-events.ts b/packages/workflow-host/src/pages/page-events.ts index b5606ad0d..9724101d0 100644 --- a/packages/workflow-host/src/pages/page-events.ts +++ b/packages/workflow-host/src/pages/page-events.ts @@ -1,5 +1,5 @@ import { playbookEventOf, definitionTypeStreamOf, type CloudEvent } from '@on.auto/definitions'; -import type { EventStore, StoredPage, StoredPlace } from '@on.auto/ledger'; +import type { LedgerStreams, StoredPage, StoredPlace } from '@on.auto/ledger'; import type { Point } from './history-points.ts'; @@ -87,7 +87,7 @@ function isPassedOver(placed: PageEvent | PassedOver): placed is PassedOver { } export async function readPage( - store: EventStore, + store: LedgerStreams, { playbookKey, after, types, definitionTypes }: PageRead, ): Promise { const page = await store.readRecorded( diff --git a/packages/workflow-host/src/projector/folder.ts b/packages/workflow-host/src/projector/folder.ts index 53b2c2a10..72d7e8468 100644 --- a/packages/workflow-host/src/projector/folder.ts +++ b/packages/workflow-host/src/projector/folder.ts @@ -1,7 +1,7 @@ +import type { Statement } from '@on.auto/ledger'; import type { Effect } from 'effect'; import type { DatabaseFailed, HostDatabase } from '../database/host-database.ts'; -import type { Statement } from '../database/statement.ts'; import type { HostNote } from '../host/host-reports.ts'; import type { Point } from '../pages/history-points.ts'; import type { ReadPage } from '../pages/page-events.ts'; diff --git a/packages/workflow-host/src/reaction-testing/kept-triggers.ts b/packages/workflow-host/src/reaction-testing/kept-triggers.ts index 17a063069..695087eb8 100644 --- a/packages/workflow-host/src/reaction-testing/kept-triggers.ts +++ b/packages/workflow-host/src/reaction-testing/kept-triggers.ts @@ -1,7 +1,7 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import type { ReactionStart } from '../reactions/reaction-options.ts'; import { until } from '../testing/until.ts'; diff --git a/packages/workflow-host/src/reaction-testing/playbook-writes.ts b/packages/workflow-host/src/reaction-testing/playbook-writes.ts index 99aca2ec6..414804edd 100644 --- a/packages/workflow-host/src/reaction-testing/playbook-writes.ts +++ b/packages/workflow-host/src/reaction-testing/playbook-writes.ts @@ -1,5 +1,5 @@ import type { Trigger } from '@on.auto/definitions'; -import { eventAppenderOf, type EventStore } from '@on.auto/ledger'; +import { eventAppenderOf, type LedgerStreams } from '@on.auto/ledger'; import { ContextSchema, messageIdOf, noLineage, type Context, type Decided, type Lineage } from '@on.auto/operations'; import { playbooksStreamOfOrg } from '@on.auto/playbooks'; import type { Json } from '@on.auto/workflow-engine'; @@ -33,14 +33,14 @@ export interface Written { readonly lineage: Lineage; } -export async function recordedWith(store: EventStore, stream: string, record: AnyRecord, written: Written) { +export async function recordedWith(store: LedgerStreams, stream: string, record: AnyRecord, written: Written) { const { version } = await store.read(stream); await Effect.runPromise( eventAppenderOf(store, AnyRecordSchema)(stream, [record], { expectedVersion: version, ...written }), ); } -export function recorded(store: EventStore, stream: string, record: AnyRecord, context: Context = byAdmin) { +export function recorded(store: LedgerStreams, stream: string, record: AnyRecord, context: Context = byAdmin) { return recordedWith(store, stream, record, { context, lineage: noLineage }); } @@ -48,7 +48,12 @@ export function emittedContext(runId: string, workflow: string, depth: number): return { ...byAdmin, runId, definitionType: 'workflow', definitionName: workflow, definitionVersion: 1, depth }; } -export function published(store: EventStore, event: PublishedEvent, context: Context = byAdmin, playbookKey = alpha) { +export function published( + store: LedgerStreams, + event: PublishedEvent, + context: Context = byAdmin, + playbookKey = alpha, +) { return recorded( store, `${playbookKey}events/${event.id}`, @@ -60,7 +65,7 @@ export function published(store: EventStore, event: PublishedEvent, context: Con ); } -export function publishedInTurn(store: EventStore, events: readonly PublishedEvent[]): Promise { +export function publishedInTurn(store: LedgerStreams, events: readonly PublishedEvent[]): Promise { return events.reduce>((before, event) => before.then(() => published(store, event)), Promise.resolve()); } @@ -87,7 +92,7 @@ export function everyTrigger(milliseconds: number): Trigger { } export function definitionRecorded( - store: EventStore, + store: LedgerStreams, { name, version, triggers, when = at }: DefinitionVersion, playbookKey = alpha, ) { @@ -108,7 +113,7 @@ export function eventRecordOf(id: string, playbookKey = alpha): string { return messageIdOf(`${playbookKey}events/${id}`, 1); } -export function definitionRetired(store: EventStore, name: string, version: number) { +export function definitionRetired(store: LedgerStreams, name: string, version: number) { return recorded( store, `${alpha}definitions/workflow`, @@ -117,14 +122,14 @@ export function definitionRetired(store: EventStore, name: string, version: numb ); } -export function playbookCreated(store: EventStore, playbook: string) { +export function playbookCreated(store: LedgerStreams, playbook: string) { return recorded(store, playbooksStreamOfOrg('acme'), { type: 'playbook_created', data: { playbook, name: playbook, description: '' }, }); } -export function playbookRenamed(store: EventStore, playbook: string) { +export function playbookRenamed(store: LedgerStreams, playbook: string) { return recorded(store, playbooksStreamOfOrg('acme'), { type: 'playbook_updated', data: { playbook, name: 'Renamed' }, @@ -139,7 +144,7 @@ export interface RunOf { readonly correlation?: string; } -export async function runRecorded(store: EventStore, run: RunOf, ending?: 'run_succeeded') { +export async function runRecorded(store: LedgerStreams, run: RunOf, ending?: 'run_succeeded') { const { runId, type, name, depth, correlation = runId } = run; const stream = `${alpha}runs/${runId}`; const context: Context = { diff --git a/packages/workflow-host/src/reaction-testing/reacting-host.ts b/packages/workflow-host/src/reaction-testing/reacting-host.ts index b4c86236a..97ea9d599 100644 --- a/packages/workflow-host/src/reaction-testing/reacting-host.ts +++ b/packages/workflow-host/src/reaction-testing/reacting-host.ts @@ -2,10 +2,10 @@ import { Effect } from 'effect'; import { onTestFinished } from 'vitest'; import type { HostDatabase } from '../database/host-database.ts'; -import type { DatabaseSettings } from '../database/host-databases.ts'; import { openWorkflowHost, type HostOptions, type WorkflowHost } from '../host/workflow-host.ts'; +import type { DatabaseSettings } from '../ledger-testing/ledger-connections.ts'; import { testMachine } from '../testing/host-documents.ts'; -import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; +import { aSQLiteFile, connectedOn, openedOn } from '../testing/host-files.ts'; import { recordingReports, recordingSettlements, type RecordingReports } from '../testing/recording-reports.ts'; import { recordedWaiting } from '../waiting-testing/recorded-waiting.ts'; import { recordedReactions, type FailingStart, type RecordedReactions } from './recorded-reactions.ts'; @@ -35,7 +35,7 @@ export async function reactingHost(options: ReactingOptions = {}): Promise Effect.succeed({ status: 'succeeded', output: null }), settle: recordingSettlements(() => false).settle, diff --git a/packages/workflow-host/src/reactions/reaction-consumers.test.ts b/packages/workflow-host/src/reactions/reaction-consumers.test.ts index 839a0adfe..bf6eac098 100644 --- a/packages/workflow-host/src/reactions/reaction-consumers.test.ts +++ b/packages/workflow-host/src/reactions/reaction-consumers.test.ts @@ -1,5 +1,5 @@ import { callKeyText, type RunInput } from '@on.auto/workflow-engine'; -import { Effect, Function } from 'effect'; +import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import type { HostDatabase } from '../database/host-database.ts'; @@ -55,7 +55,6 @@ function decliningEveryOffer(database: HostDatabase): Declining { reports: { unsettled: () => Effect.void, trouble: () => Effect.void, - lostConnection: Function.constVoid, note: (note) => Effect.sync(() => { notes.push(note); diff --git a/packages/workflow-host/src/reactions/refusals.ts b/packages/workflow-host/src/reactions/refusals.ts index 0010f353c..ec614c9ca 100644 --- a/packages/workflow-host/src/reactions/refusals.ts +++ b/packages/workflow-host/src/reactions/refusals.ts @@ -1,9 +1,8 @@ import { reactionRefusalDecider, ReactionRefusedSchema, reactionsStreamKind } from '@on.auto/definitions'; -import { decisionLoop, eventAppenderOf } from '@on.auto/ledger'; +import { decisionLoop, eventAppenderOf, statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { playbookOfKey } from '../follower/record-steps.ts'; interface RefusedWorkflow { diff --git a/packages/workflow-host/src/reactions/start-rates.test.ts b/packages/workflow-host/src/reactions/start-rates.test.ts index 75bff4e6e..67d0d3ab8 100644 --- a/packages/workflow-host/src/reactions/start-rates.test.ts +++ b/packages/workflow-host/src/reactions/start-rates.test.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { deliverySweeps } from '../follower/consumers.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; import { StartRefused, type ReactionStart } from './reaction-options.ts'; diff --git a/packages/workflow-host/src/reactions/start-rates.ts b/packages/workflow-host/src/reactions/start-rates.ts index 36ff48585..95787bb62 100644 --- a/packages/workflow-host/src/reactions/start-rates.ts +++ b/packages/workflow-host/src/reactions/start-rates.ts @@ -1,9 +1,9 @@ import { triggerNamed } from '@on.auto/definitions'; +import { statement } from '@on.auto/ledger'; import { StartingTriggerSchema } from '@on.auto/operations'; import { Effect, Schema, type Tracer } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { DeliveryFailed, deliverySweeps } from '../follower/consumers.ts'; import { linksTo } from '../runs/run-spans.ts'; import type { ReactionStart, StartReaction } from './reaction-options.ts'; diff --git a/packages/workflow-host/src/runs/ledger-run-store.test.ts b/packages/workflow-host/src/runs/ledger-run-store.test.ts index 7b6ea63f3..70c789a9b 100644 --- a/packages/workflow-host/src/runs/ledger-run-store.test.ts +++ b/packages/workflow-host/src/runs/ledger-run-store.test.ts @@ -1,9 +1,9 @@ +import { statement } from '@on.auto/ledger'; import { ledgerLayer } from '@on.auto/ledger/sqlite3'; import { Ledger } from '@on.auto/operations'; import { Effect, ManagedRuntime } from 'effect'; import { describe, expect, it, onTestFinished } from 'vitest'; -import { statement } from '../database/statement.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; diff --git a/packages/workflow-host/src/runs/ledger-run-store.ts b/packages/workflow-host/src/runs/ledger-run-store.ts index 33b5673b3..add5b2961 100644 --- a/packages/workflow-host/src/runs/ledger-run-store.ts +++ b/packages/workflow-host/src/runs/ledger-run-store.ts @@ -1,4 +1,4 @@ -import { eventAppenderOf, eventCodecOf } from '@on.auto/ledger'; +import { eventAppenderOf, eventCodecOf, statement } from '@on.auto/ledger'; import { RunLogRecordSchema, runLogRecordOf, @@ -10,7 +10,6 @@ import { import { Effect } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { runForgotten } from '../listeners/listener-rows.ts'; import { runLogStreamOf } from './run-address.ts'; import { lineageOfRecord } from './run-lineage.ts'; diff --git a/packages/workflow-host/src/runs/snapshot-chunks.test.ts b/packages/workflow-host/src/runs/snapshot-chunks.test.ts index 14d810c60..a67a31af3 100644 --- a/packages/workflow-host/src/runs/snapshot-chunks.test.ts +++ b/packages/workflow-host/src/runs/snapshot-chunks.test.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import { snapshotOf } from '@on.auto/workflow-engine'; import { Cause, Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { statement } from '../database/statement.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; diff --git a/packages/workflow-host/src/runs/snapshot-chunks.ts b/packages/workflow-host/src/runs/snapshot-chunks.ts index 8e7c59cc7..59fb5722f 100644 --- a/packages/workflow-host/src/runs/snapshot-chunks.ts +++ b/packages/workflow-host/src/runs/snapshot-chunks.ts @@ -1,3 +1,4 @@ +import { statement } from '@on.auto/ledger'; import { UnreadableRun, snapshotChunks, @@ -8,7 +9,6 @@ import { import { Effect, Result, Schema } from 'effect'; import { rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; const ChunkRow = Schema.Struct({ version: WholeNumber, chunks: WholeNumber, bytes: WholeNumber, text: Schema.String }); diff --git a/packages/workflow-host/src/schedules/schedule-firing.test.ts b/packages/workflow-host/src/schedules/schedule-firing.test.ts index 8f8029b33..7c2e4b17c 100644 --- a/packages/workflow-host/src/schedules/schedule-firing.test.ts +++ b/packages/workflow-host/src/schedules/schedule-firing.test.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { rowsOf } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { movedClock, type MovedClock } from '../reaction-testing/moved-clock.ts'; import { at, diff --git a/packages/workflow-host/src/schedules/schedule-firing.ts b/packages/workflow-host/src/schedules/schedule-firing.ts index 4ce62a7ae..2ace09436 100644 --- a/packages/workflow-host/src/schedules/schedule-firing.ts +++ b/packages/workflow-host/src/schedules/schedule-firing.ts @@ -1,8 +1,8 @@ import { triggerNamed } from '@on.auto/definitions'; +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { reactionRunIdOf } from '../reactions/reaction-ids.ts'; import type { ReactionStart, StartReaction } from '../reactions/reaction-options.ts'; import type { Refusals } from '../reactions/refusals.ts'; diff --git a/packages/workflow-host/src/schedules/schedule-rows.ts b/packages/workflow-host/src/schedules/schedule-rows.ts index 869af3fd1..a230cc941 100644 --- a/packages/workflow-host/src/schedules/schedule-rows.ts +++ b/packages/workflow-host/src/schedules/schedule-rows.ts @@ -1,8 +1,8 @@ import { ScheduleTriggerSchema, type ScheduleTrigger } from '@on.auto/definitions'; +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; export interface Schedule { readonly playbookKey: string; diff --git a/packages/workflow-host/src/settlement/ledger-record-store.ts b/packages/workflow-host/src/settlement/ledger-record-store.ts index d8f3ba5cc..9fe9703cd 100644 --- a/packages/workflow-host/src/settlement/ledger-record-store.ts +++ b/packages/workflow-host/src/settlement/ledger-record-store.ts @@ -1,10 +1,10 @@ import type { SettleRun } from '@on.auto/definitions'; +import { statement } from '@on.auto/ledger'; import { NotFound, SettlementSchema, type Lineage, type Settlement } from '@on.auto/operations'; import { DispatchFailed, type RecordStore, type SettleReceipt } from '@on.auto/workflow-engine'; import { Cause, Effect, Equal, Option, Predicate, Schema } from 'effect'; import { rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import type { HostNote } from '../host/host-reports.ts'; import { addressOfRun } from '../runs/run-address.ts'; import { lineageOfSettlement } from '../runs/run-lineage.ts'; diff --git a/packages/workflow-host/src/settlement/settle-attempts.ts b/packages/workflow-host/src/settlement/settle-attempts.ts index 2711f0901..9d0570613 100644 --- a/packages/workflow-host/src/settlement/settle-attempts.ts +++ b/packages/workflow-host/src/settlement/settle-attempts.ts @@ -1,7 +1,7 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { oneRowOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; export const settleAttemptsBeforeBackingOff = 20; diff --git a/packages/workflow-host/src/sweeps/playbook-sweeps.ts b/packages/workflow-host/src/sweeps/playbook-sweeps.ts index d0f406b0e..157de25fa 100644 --- a/packages/workflow-host/src/sweeps/playbook-sweeps.ts +++ b/packages/workflow-host/src/sweeps/playbook-sweeps.ts @@ -1,4 +1,4 @@ -import type { AppendedStreams, EventStore } from '@on.auto/ledger'; +import type { AppendedStreams, LedgerStreams } from '@on.auto/ledger'; import { Effect } from 'effect'; import type { FollowedPlaybook, FollowedPlaybooks } from '../follower/followed-playbooks.ts'; @@ -81,7 +81,7 @@ function pendingPlaybooks() { }; } -type AppendedStore = Pick; +type AppendedStore = Pick; export function playbookSweepsOn(store: AppendedStore, playbooks: FollowedPlaybooks): PlaybookSweeps { const state: { through: AppendedStreams['through'] | undefined; round: string | null } = { diff --git a/packages/workflow-host/src/testing/host-files.ts b/packages/workflow-host/src/testing/host-files.ts index d75b7f58c..ec30831fe 100644 --- a/packages/workflow-host/src/testing/host-files.ts +++ b/packages/workflow-host/src/testing/host-files.ts @@ -2,15 +2,21 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { statement, type ConnectionPorts, type Statement } from '@on.auto/ledger'; import { plainPoolsOn } from '@on.auto/ledger/postgresql'; -import { Effect, Function, Schema } from 'effect'; +import { Effect, Schema } from 'effect'; import { expect, onTestFinished } from 'vitest'; import { oneRowOf, rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { openHostDatabase, type DatabaseSettings } from '../database/host-databases.ts'; -import { statement, type Statement } from '../database/statement.ts'; +import { + hostDatabaseOpenedOn, + ledgerConnectedOn, + type DatabaseSettings, +} from '../ledger-testing/ledger-connections.ts'; import { until } from './until.ts'; +export type { DatabaseSettings } from '../ledger-testing/ledger-connections.ts'; + export type SettingsOf = () => Promise; const LeaseRow = Schema.Struct({ holder: Schema.String, expires_at: WholeNumber }); @@ -35,9 +41,15 @@ export function onPostgreSQLAt(url: string): DatabaseSettings { return { store: 'postgresql', pools: plainPoolsOn(url) }; } +export async function connectedOn(settings: DatabaseSettings): Promise { + const { connection, close } = await ledgerConnectedOn(settings); + onTestFinished(close); + return connection; +} + export async function openedOn(settings: DatabaseSettings, tables: readonly Statement[] = []): Promise { - const database = await openHostDatabase(settings, Function.constVoid, tables); - onTestFinished(() => database.close()); + const database = await hostDatabaseOpenedOn(settings, tables); + onTestFinished(database.close); return database; } diff --git a/packages/workflow-host/src/testing/host-processes.ts b/packages/workflow-host/src/testing/host-processes.ts index 2fb7c31b6..185bad0f0 100644 --- a/packages/workflow-host/src/testing/host-processes.ts +++ b/packages/workflow-host/src/testing/host-processes.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { spawnForTheTest } from '@on.auto/operations/testing'; import { expect } from 'vitest'; -import type { DatabaseSettings } from '../database/host-databases.ts'; +import type { DatabaseSettings } from '../ledger-testing/ledger-connections.ts'; import { onPostgreSQLAt } from './host-files.ts'; const hostProcess = fileURLToPath(new URL('../../host-process.ts', import.meta.url)); diff --git a/packages/workflow-host/src/testing/host-runs.ts b/packages/workflow-host/src/testing/host-runs.ts index 637fa6c6c..54e2a2ad9 100644 --- a/packages/workflow-host/src/testing/host-runs.ts +++ b/packages/workflow-host/src/testing/host-runs.ts @@ -4,6 +4,7 @@ import { Effect } from 'effect'; import { onTestFinished } from 'vitest'; import { openWorkflowHost, type HostOptions, type WorkflowHost } from '../host/workflow-host.ts'; +import { storeSettingsOf, type HostedDatabase } from '../ledger-testing/hosted-databases.ts'; import { recordedReactions } from '../reaction-testing/recorded-reactions.ts'; import { recordedWaiting } from '../waiting-testing/recorded-waiting.ts'; import { testMachine } from './host-documents.ts'; @@ -78,9 +79,9 @@ export function recordedOptionsOf(options: HostedOptions = {}): RecordedOptions }; } -export async function hostedOn(settings: HostOptions['database'], options: HostedOptions = {}): Promise { +export async function hostedOn(database: HostedDatabase, options: HostedOptions = {}): Promise { const { options: hostOptions, ...records } = recordedOptionsOf(options); - const host = await openWorkflowHost({ database: settings, ...hostOptions }); + const host = await openWorkflowHost({ database: await storeSettingsOf(database), ...hostOptions }); onTestFinished(() => host.stop()); return { host, ...records }; } diff --git a/packages/workflow-host/src/testing/index.ts b/packages/workflow-host/src/testing/index.ts index bd1f9a8a7..a01b46096 100644 --- a/packages/workflow-host/src/testing/index.ts +++ b/packages/workflow-host/src/testing/index.ts @@ -6,3 +6,4 @@ export { type ProjectedFolders, type ProjectingOptions, } from '../folder-testing/projected-folders.ts'; +export { workflowHostOnALedger, type DatabaseSettings } from '../ledger-testing/ledger-connections.ts'; diff --git a/packages/workflow-host/src/testing/recording-reports.ts b/packages/workflow-host/src/testing/recording-reports.ts index 2adbe4e4d..83411fb30 100644 --- a/packages/workflow-host/src/testing/recording-reports.ts +++ b/packages/workflow-host/src/testing/recording-reports.ts @@ -37,7 +37,6 @@ export function recordingReports(): RecordingReports { Effect.sync(() => { noted(what); }), - lostConnection: noted, note: (note) => Effect.sync(() => { notes.push(note); diff --git a/packages/workflow-host/src/timers/sql-timers.ts b/packages/workflow-host/src/timers/sql-timers.ts index 2c604e85f..6843603aa 100644 --- a/packages/workflow-host/src/timers/sql-timers.ts +++ b/packages/workflow-host/src/timers/sql-timers.ts @@ -1,3 +1,4 @@ +import { statement } from '@on.auto/ledger'; import { DispatchFailed, type ArmReceipt, @@ -9,7 +10,6 @@ import { import { Effect, Schema } from 'effect'; import { oneRowOf, rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; export interface DueTimer { readonly runKey: string; diff --git a/packages/workflow-host/src/triggers/definition-records.test.ts b/packages/workflow-host/src/triggers/definition-records.test.ts index 88e4a81bd..8581b8159 100644 --- a/packages/workflow-host/src/triggers/definition-records.test.ts +++ b/packages/workflow-host/src/triggers/definition-records.test.ts @@ -1,9 +1,9 @@ import type { Trigger } from '@on.auto/definitions'; +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { definitionRecordsKeepingTheirOwnStops } from '../reaction-testing/own-stops.ts'; import { cronTrigger, eventTrigger, everyTrigger } from '../reaction-testing/playbook-writes.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; diff --git a/packages/workflow-host/src/triggers/several-triggers.test.ts b/packages/workflow-host/src/triggers/several-triggers.test.ts index f81c7e750..ba25b5381 100644 --- a/packages/workflow-host/src/triggers/several-triggers.test.ts +++ b/packages/workflow-host/src/triggers/several-triggers.test.ts @@ -1,7 +1,7 @@ +import { statement } from '@on.auto/ledger'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { statement } from '../database/statement.ts'; import { startsReaching, untilScheduleRuns } from '../reaction-testing/kept-triggers.ts'; import { movedClock } from '../reaction-testing/moved-clock.ts'; import { diff --git a/packages/workflow-host/src/triggers/trigger-rebuild.test.ts b/packages/workflow-host/src/triggers/trigger-rebuild.test.ts index 5b138212d..eb97932d2 100644 --- a/packages/workflow-host/src/triggers/trigger-rebuild.test.ts +++ b/packages/workflow-host/src/triggers/trigger-rebuild.test.ts @@ -1,4 +1,4 @@ -import type { EventStore } from '@on.auto/ledger'; +import type { LedgerStreams } from '@on.auto/ledger'; import { describe, expect, it } from 'vitest'; import { triggerRowsOf, untilTriggersAt } from '../reaction-testing/kept-triggers.ts'; @@ -26,7 +26,7 @@ function isoAt(minutes: number): string { return new Date(activatedAt + minutes * aMinute).toISOString(); } -async function definitionsOfThePlaybook(store: EventStore): Promise { +async function definitionsOfThePlaybook(store: LedgerStreams): Promise { const closed = eventTrigger({ type: 'com.acme.closed' }); await playbookCreated(store, 'alpha'); await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed, everyTrigger(15 * aMinute)] }); diff --git a/packages/workflow-host/src/triggers/trigger-rows.ts b/packages/workflow-host/src/triggers/trigger-rows.ts index a347daeec..016fc6534 100644 --- a/packages/workflow-host/src/triggers/trigger-rows.ts +++ b/packages/workflow-host/src/triggers/trigger-rows.ts @@ -1,8 +1,8 @@ import { EventTriggerSchema, TriggerSchema, type Trigger, type TriggerFilter } from '@on.auto/definitions'; +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { nextAfter } from '../schedules/schedule-times.ts'; import { triggerChangesOf, type TriggerChange } from './trigger-changes.ts'; diff --git a/packages/workflow-host/src/views-testing/playbook-appends.ts b/packages/workflow-host/src/views-testing/playbook-appends.ts index 858faff71..7705cc790 100644 --- a/packages/workflow-host/src/views-testing/playbook-appends.ts +++ b/packages/workflow-host/src/views-testing/playbook-appends.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import type { AppendSignal, EncodedEvent, EventStore } from '@on.auto/ledger'; +import type { AppendSignal, EncodedEvent, LedgerStreams } from '@on.auto/ledger'; import { streamPrefixOfPlaybook, type Context } from '@on.auto/operations'; import type { Schema } from 'effect'; @@ -37,7 +37,7 @@ function ranContext(subject: string, at: string): Context { return { by: 'acme-admin', at, definitionType: type, definitionName: name, definitionVersion: 1 }; } -function appendOn(store: EventStore, appends: AppendSignal): Append { +function appendOn(store: LedgerStreams, appends: AppendSignal): Append { const streams = new Map(); return async (stream, events, context = byAdmin) => { const version = streams.get(stream) ?? 0; @@ -82,7 +82,7 @@ function savedBy(append: Append, definitions: Map): PlaybookAppe }; } -export function playbookAppends(store: EventStore, appends: AppendSignal): PlaybookAppends { +export function playbookAppends(store: LedgerStreams, appends: AppendSignal): PlaybookAppends { const append = appendOn(store, appends); const ran = ranBy(append); const definitions = new Map(); diff --git a/packages/workflow-host/src/views-testing/view-harness.ts b/packages/workflow-host/src/views-testing/view-harness.ts index 250265bc7..7d05274b5 100644 --- a/packages/workflow-host/src/views-testing/view-harness.ts +++ b/packages/workflow-host/src/views-testing/view-harness.ts @@ -1,8 +1,8 @@ import { appendSignal, type AppendSignal } from '@on.auto/ledger'; import { onTestFinished } from 'vitest'; -import type { DatabaseSettings } from '../database/host-databases.ts'; import { openWorkflowStore, type WorkflowStore } from '../host/workflow-store.ts'; +import { connectedOn, type DatabaseSettings } from '../testing/host-files.ts'; import { recordingReports, type RecordingReports } from '../testing/recording-reports.ts'; import { harnessProjectors, type HarnessProjectors } from './harness-projectors.ts'; import { playbookAppends, type PlaybookAppends } from './playbook-appends.ts'; @@ -27,12 +27,11 @@ export async function viewHarness( const pool = testFoldPoolOf(poolSettings); const appends = appendSignal(); const settingsOf = settingsOver(pool, appends); - const store = await openWorkflowStore(settings, reports.reports.lostConnection, settingsOf({ besides }).folders); + const store = await openWorkflowStore(await connectedOn(settings), settingsOf({ besides }).folders); const projectors = harnessProjectors({ database: store.database, reports: reports.reports, settingsOf }); onTestFinished(async () => { await projectors.stopProjectors(); await pool.close(); - await store.database.close(); }); return { ...playbookAppends(store.database.store, appends), diff --git a/packages/workflow-host/src/views/view-statements.ts b/packages/workflow-host/src/views/view-statements.ts index 589bc8653..0481ad59e 100644 --- a/packages/workflow-host/src/views/view-statements.ts +++ b/packages/workflow-host/src/views/view-statements.ts @@ -1,6 +1,6 @@ +import { type Statement, statement } from '@on.auto/ledger'; import type { Schema } from 'effect'; -import { statement, type Statement } from '../database/statement.ts'; import { pointText } from '../pages/history-points.ts'; import type { RowPhase } from '../projector/folder.ts'; import type { FoldedEvent, ViewRow, ViewStall } from './view-rows.ts'; diff --git a/packages/workflow-host/src/waiting-testing/followed-host.ts b/packages/workflow-host/src/waiting-testing/followed-host.ts index a66ee3f36..fba52d61a 100644 --- a/packages/workflow-host/src/waiting-testing/followed-host.ts +++ b/packages/workflow-host/src/waiting-testing/followed-host.ts @@ -1,9 +1,8 @@ -import { recordedReaderOf } from '@on.auto/ledger'; +import { recordedReaderOf, statement } from '@on.auto/ledger'; import type { Settlement } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; import { alpha, playbookCreated } from '../reaction-testing/playbook-writes.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostedOn, type HostedOptions, type HostedRuns } from '../testing/host-runs.ts'; diff --git a/packages/workflow-host/src/waiting-testing/held-follower.ts b/packages/workflow-host/src/waiting-testing/held-follower.ts index 234881b27..e6dbc53d1 100644 --- a/packages/workflow-host/src/waiting-testing/held-follower.ts +++ b/packages/workflow-host/src/waiting-testing/held-follower.ts @@ -1,8 +1,6 @@ import { playbooksStreamOfOrg } from '@on.auto/playbooks'; -import { Function } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; -import { openHostDatabase } from '../database/host-databases.ts'; import { playbookCreated } from '../reaction-testing/playbook-writes.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostedOn, type HostedOptions } from '../testing/host-runs.ts'; @@ -33,7 +31,7 @@ export async function heldFollower(options: HostedOptions = {}): Promise(); - const held = registryHeldUntil(released.promise, await openHostDatabase(settings, Function.constVoid)); + const held = registryHeldUntil(released.promise, await openedOn(settings)); const hosted = await hostedOn({ database: held }, options); return { database, diff --git a/packages/workflow-host/src/waiting-testing/waiting-parent.ts b/packages/workflow-host/src/waiting-testing/waiting-parent.ts index a71af4236..5622edf42 100644 --- a/packages/workflow-host/src/waiting-testing/waiting-parent.ts +++ b/packages/workflow-host/src/waiting-testing/waiting-parent.ts @@ -1,7 +1,7 @@ +import { statement } from '@on.auto/ledger'; import { Effect, Schema } from 'effect'; import { waitingCallsOf } from '../calls/call-rows.ts'; -import { statement } from '../database/statement.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import type { HostedOptions } from '../testing/host-runs.ts'; import { until } from '../testing/until.ts'; diff --git a/packages/workflow-host/src/waiting/pending-cancel-rows.ts b/packages/workflow-host/src/waiting/pending-cancel-rows.ts index 515d65b04..20fada1aa 100644 --- a/packages/workflow-host/src/waiting/pending-cancel-rows.ts +++ b/packages/workflow-host/src/waiting/pending-cancel-rows.ts @@ -1,8 +1,8 @@ +import { statement } from '@on.auto/ledger'; import { CancelOrderSchema, type CancelOrder } from '@on.auto/workflow-engine'; import { Effect, Schema } from 'effect'; import { rowsOf, type DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { statement } from '../database/statement.ts'; export interface PendingCancelRow { readonly runKey: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3abbb2a86..4feea7093 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1078,12 +1078,6 @@ importers: packages/workflow-host: dependencies: - '@event-driven-io/dumbo': - specifier: 0.13.0-beta.56 - version: 0.13.0-beta.56(@types/pg@8.23.1)(pg@8.23.1)(sqlite3@6.0.1) - '@event-driven-io/emmett-sqlite': - specifier: 0.43.0-beta.50 - version: 0.43.0-beta.50(@aws-sdk/credential-providers@3.1149.0)(@types/pg@8.23.1)(commander@14.0.3)(pg@8.23.1)(sqlite3@6.0.1)(supports-color@8.1.1)(uuid@14.0.2) '@on.auto/definitions': specifier: workspace:* version: link:../definitions @@ -1108,12 +1102,6 @@ importers: effect: specifier: 'catalog:' version: 4.0.0 - pg: - specifier: 8.23.1 - version: 8.23.1 - sqlite3: - specifier: 6.0.1 - version: 6.0.1 devDependencies: '@types/pg': specifier: 8.23.1 @@ -1121,6 +1109,9 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.3(vitest@5.0.3) + pg: + specifier: 8.23.1 + version: 8.23.1 vitest: specifier: 'catalog:' version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))