@shivaedev/sql 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +175 -161
- package/dist/PostgresListenerError.d.ts +10 -0
- package/dist/PostgresListenerError.d.ts.map +1 -0
- package/dist/PostgresListenerError.js +7 -0
- package/dist/PostgresListenerError.js.map +1 -0
- package/dist/SchemaComponent.d.ts +5 -5
- package/dist/SchemaComponent.d.ts.map +1 -1
- package/dist/SchemaComponent.js +1 -2
- package/dist/SchemaComponent.js.map +1 -1
- package/dist/WorkerRelease.d.ts +7 -8
- package/dist/WorkerRelease.d.ts.map +1 -1
- package/dist/WorkerRelease.js +10 -4
- package/dist/WorkerRelease.js.map +1 -1
- package/dist/adapters/makePostgresClient.d.ts +14 -0
- package/dist/adapters/makePostgresClient.d.ts.map +1 -0
- package/dist/adapters/makePostgresClient.js +63 -0
- package/dist/adapters/makePostgresClient.js.map +1 -0
- package/dist/adapters/postgresStream.d.ts +4 -0
- package/dist/adapters/postgresStream.d.ts.map +1 -0
- package/dist/adapters/postgresStream.js +20 -0
- package/dist/adapters/postgresStream.js.map +1 -0
- package/dist/assertPostgresMigrations.d.ts +1 -1
- package/dist/assertPostgresMigrations.d.ts.map +1 -1
- package/dist/assertPostgresMigrations.js.map +1 -1
- package/dist/coordinateWorkerRelease.d.ts +3 -3
- package/dist/coordinateWorkerRelease.d.ts.map +1 -1
- package/dist/coordinateWorkerRelease.js +14 -5
- package/dist/coordinateWorkerRelease.js.map +1 -1
- package/dist/databaseAddress.d.ts +4 -0
- package/dist/databaseAddress.d.ts.map +1 -0
- package/dist/databaseAddress.js +25 -0
- package/dist/databaseAddress.js.map +1 -0
- package/dist/internal/CommandRecovery.d.ts +3 -0
- package/dist/internal/CommandRecovery.d.ts.map +1 -0
- package/dist/internal/CommandRecovery.js +5 -0
- package/dist/internal/CommandRecovery.js.map +1 -0
- package/dist/listenForChanges.d.ts +3 -1
- package/dist/listenForChanges.d.ts.map +1 -1
- package/dist/listenForChanges.js +31 -34
- package/dist/listenForChanges.js.map +1 -1
- package/dist/migrations.d.ts +5 -4
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +7 -3
- package/dist/migrations.js.map +1 -1
- package/dist/notifyPayload.d.ts +1 -0
- package/dist/notifyPayload.d.ts.map +1 -1
- package/dist/notifyPayload.js +2 -0
- package/dist/notifyPayload.js.map +1 -1
- package/dist/pendingPostgresMigrations.d.ts +4 -3
- package/dist/pendingPostgresMigrations.d.ts.map +1 -1
- package/dist/pendingPostgresMigrations.js +8 -4
- package/dist/pendingPostgresMigrations.js.map +1 -1
- package/dist/postgresChanges.d.ts +3 -1
- package/dist/postgresChanges.d.ts.map +1 -1
- package/dist/postgresChanges.js +4 -2
- package/dist/postgresChanges.js.map +1 -1
- package/dist/postgresLayer.d.ts +11 -0
- package/dist/postgresLayer.d.ts.map +1 -0
- package/dist/postgresLayer.js +19 -0
- package/dist/postgresLayer.js.map +1 -0
- package/dist/postgresRelease.d.ts +9 -6
- package/dist/postgresRelease.d.ts.map +1 -1
- package/dist/postgresRelease.js +3 -3
- package/dist/postgresRelease.js.map +1 -1
- package/dist/releaseWorkers.d.ts +2 -2
- package/dist/releaseWorkers.d.ts.map +1 -1
- package/dist/releaseWorkers.js +3 -3
- package/dist/releaseWorkers.js.map +1 -1
- package/dist/testing.d.ts +2 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +1 -1
- package/dist/transact.js +1 -1
- package/dist/transact.js.map +1 -1
- package/package.json +17 -11
- package/src/PostgresListenerError.ts +9 -0
- package/src/SchemaComponent.ts +6 -6
- package/src/WorkerRelease.ts +11 -9
- package/src/adapters/makePostgresClient.ts +72 -0
- package/src/adapters/postgresStream.ts +26 -0
- package/src/assertPostgresMigrations.ts +2 -2
- package/src/coordinateWorkerRelease.ts +31 -5
- package/src/databaseAddress.ts +25 -0
- package/src/internal/CommandRecovery.ts +5 -0
- package/src/listenForChanges.ts +41 -39
- package/src/migrations.ts +14 -7
- package/src/notifyPayload.ts +2 -0
- package/src/pendingPostgresMigrations.ts +16 -7
- package/src/postgresChanges.ts +10 -2
- package/src/postgresLayer.ts +27 -0
- package/src/postgresRelease.ts +9 -5
- package/src/releaseWorkers.ts +5 -3
- package/src/testing.ts +4 -8
- package/src/transact.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.26.0 - 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `@shivaedev/server`: Configure a direct or session-mode change listener with `config.listenDatabaseUrl` or `LISTEN_DATABASE_URL`, falling back to the application connection when unset.
|
|
8
|
+
- `@shivaedev/sql`: Supply `listenerClient` separately from the writing `client` to `postgresChanges`; set `connectionSetting` to identify that connection in diagnostics.
|
|
9
|
+
- `@shivaedev/sql/postgresLayer.ts` provides `postgresLayer(url, options)` with one native `PgPool` shared by `PgClient` and `SqlClient`. Reserved connections and client queries share `maxConnections`, and Layer shutdown waits for every socket to close.
|
|
10
|
+
- `appDatabase` and database-backed `appHost` expose the application's native `PgPool` service for packages that need reserved PostgreSQL connections.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Retry command-declared invalidation publication after the handler returns, with two bounded exponential backoffs. Exhausted retries preserve the committed result and resynchronize local live streams, then broadcast Resync to other PostgreSQL hosts when the change channel is reachable.
|
|
15
|
+
- Clarify that atomic record and bucket invalidation comes from the stored model and repository; command `invalidates` keys remain after-commit. No consumer API changes or migration are required.
|
|
16
|
+
- Retry worker-release heartbeat and poll failures on a bounded schedule, log their causes, and close the worker scope with the original cause when recovery fails. Heartbeats continue while jobs drain after a connection blip.
|
|
17
|
+
- Log change-listener loss and reconnect causes, and bound listener sub-scope cleanup without rewriting scope finalizers.
|
|
18
|
+
- Fail application database startup when its first PostgreSQL LISTEN or heartbeat fails, instead of waiting indefinitely.
|
|
19
|
+
- SQL decodes migration ledger, release ownership and change-coverage rows before using them. Malformed rows fail with `SchemaError` instead of being treated as current, pending or a different owner.
|
|
20
|
+
- SQL generates worker identities and change-listener heartbeat tokens through Effect's `Random` service.
|
|
21
|
+
|
|
22
|
+
### Breaking
|
|
23
|
+
|
|
24
|
+
- Listener startup deadlines now fail with `PostgresListenerError`, naming the connection setting, instead of `SqlError`. Consumers handling startup failures must also handle `PostgresListenerError` from `@shivaedev/sql/PostgresListenerError.ts`. Hosts using transaction-pooled application URLs must set `LISTEN_DATABASE_URL` or `config.listenDatabaseUrl` to a direct or session-mode connection to the same database. Native SQL startup failures still use `SqlError`.
|
|
25
|
+
- Server's PostgreSQL construction and address helpers move to sql. Consumers of `@shivaedev/server/adapters/postgresClient.ts` must compose `postgresLayer` from `@shivaedev/sql/postgresLayer.ts` and read the native `PgPool` or `PgClient` service instead. Address-only consumers import `databaseAddress` from `@shivaedev/sql/databaseAddress.ts` instead of `@shivaedev/server/databaseAddress.ts`.
|
|
26
|
+
- `postgresChanges` and `listenForChanges` now expose `SqlError` during acquisition. Consumers that required an infallible Layer or Effect must handle or propagate this error. `heartbeatTimeout` bounds the first LISTEN acquisition and its first heartbeat; retry startup at the application boundary if desired. Established listeners continue to reconnect.
|
|
27
|
+
- Schema component failures now reach SQL migration and readiness callers, server startup and test-sql migration helpers with their original error, tag and cause, and log the component name. Handle the component's own error instead of a wrapping `MigrationError` with kind `Failed`. When annotating migration options with fallible components, supply their error type as the second generic parameter; the default describes components that cannot fail.
|
|
28
|
+
- Declare `WorkerRelease` through `defineService`. Consumers using the old class as a type must use `Context.Service.Identifier<typeof WorkerRelease>`; yielding the service and providing its value stay the same.
|
|
29
|
+
|
|
30
|
+
## 0.25.0 - 2026-10-30
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Require the Effect 4.0.6 stack.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Publish committed SQL invalidations immediately for their owning database, including when another database has an open transaction.
|
|
39
|
+
|
|
40
|
+
### Breaking
|
|
41
|
+
|
|
42
|
+
- `@shivaedev/sql`: migration-body SQL failures now propagate as native typed `MigrationError` failures. Handle them with `Effect.catchTag("MigrationError", ...)` instead of defect recovery; migration-body defects remain defects.
|
|
43
|
+
- `@shivaedev/http`: bounded request-body reads use the native reader and its connection cleanup. Do not assume a rejected upload can drain an arbitrarily large unread body and reuse its connection; allow the HTTP client to open a new connection. The optional Node platform peer is no longer required by this package.
|
|
44
|
+
|
|
3
45
|
## 0.24.0 - 2026-10-28
|
|
4
46
|
|
|
5
47
|
### Added
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ const rows = yield* lines.findMany({
|
|
|
21
21
|
|
|
22
22
|
### How to think about it
|
|
23
23
|
|
|
24
|
-
The application owns the database schema
|
|
24
|
+
The application owns the database schema. For PostgreSQL, `postgresLayer` provides one native pool and the Effect SQL clients that use it. Configure new clients with `clientOptions` to map camelCase model fields to snake_case PostgreSQL columns. A **model** describes decoded row fields and the variants used for insertion, updating and JSON. A **repository** combines native `SqlModel` CRUD with a small `findMany` query whose filters and selected results use those fields.
|
|
25
25
|
|
|
26
26
|
Repository writes announce their table and returned row keys automatically. Every written row keeps its record key, including in bulk writes. Fields marked with `bucket` also announce their old and new bucket keys. Each write uses `transact`; start a surrounding multi-write boundary with `transact` too, so keys leave only after its owning database commits. Bare native transactions are refused because their commit cannot be observed. The change ownership comes from `changes`; this package supplies the SQL boundary and the Reactivity publisher.
|
|
27
27
|
|
|
@@ -29,6 +29,25 @@ Migrations are ordinary Effects loaded by native Migrator. `migratePostgres` add
|
|
|
29
29
|
|
|
30
30
|
The work has three parts: describe the model and provide its database once, choose repository operations per feature, and put each changed write inside the transaction that owns its commit.
|
|
31
31
|
|
|
32
|
+
### One PostgreSQL pool per application
|
|
33
|
+
|
|
34
|
+
`postgresLayer(url, options)` from `@shivaedev/sql/postgresLayer.ts` provides the native `PgPool` service from `@effect/sql-pg/PgPool`, `PgClient` and `SqlClient` together. `options` accepts `applicationName`, `maxConnections`, `transformQueryNames` and `transformResultNames`. Queries, transactions and raw connections reserved with `yield* pool.reserve` share the same `maxConnections` budget. A reservation holds its connection until its scope closes; release it before waiting for work that needs that slot.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import * as PgPool from "@effect/sql-pg/PgPool";
|
|
38
|
+
import { Effect, Redacted } from "effect";
|
|
39
|
+
import { postgresLayer } from "@shivaedev/sql/postgresLayer.ts";
|
|
40
|
+
|
|
41
|
+
const database = postgresLayer(Redacted.make("postgresql://localhost/example"), { maxConnections: 4 });
|
|
42
|
+
const backend = Effect.gen(function* () {
|
|
43
|
+
const pool = yield* PgPool.PgPool;
|
|
44
|
+
const connection = yield* pool.reserve;
|
|
45
|
+
return yield* connection.query("select pg_backend_pid() as pid");
|
|
46
|
+
}).pipe(Effect.scoped, Effect.provide(database));
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The Layer owns the pool and waits for every socket to close during shutdown. A reserved connection sees committed writes from client transactions in the same database; it does not implicitly join a client's open transaction. `appDatabase` and database-backed `appHost` from `@shivaedev/server/appHost.ts` compose this Layer and expose the pool to application services.
|
|
50
|
+
|
|
32
51
|
### SQL names: configure the client once
|
|
33
52
|
|
|
34
53
|
```ts
|
|
@@ -36,14 +55,16 @@ import * as PgClient from "@effect/sql-pg/PgClient";
|
|
|
36
55
|
import { Redacted } from "effect";
|
|
37
56
|
import { clientOptions } from "@shivaedev/sql/clientOptions.ts";
|
|
38
57
|
|
|
39
|
-
const database = PgClient.layer(
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
const database = PgClient.layer(
|
|
59
|
+
clientOptions({
|
|
60
|
+
url: Redacted.make("postgresql://localhost/example"),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
42
63
|
```
|
|
43
64
|
|
|
44
65
|
`clientOptions` enables Effect SQL's `transformQueryNames` with Effect's `String.camelToSnake` and `transformResultNames` with `String.snakeToCamel` by default. A model field `createdAt` writes the migration-created `created_at` column, and returned rows contain `createdAt`. Inserts, selects, filters, ordering, updates and raw SQL share the client's transforms; column aliases are unnecessary. Write literal migration SQL with snake_case columns. In tagged SQL, interpolated identifiers such as `sql("createdAt")` are transformed; literal SQL text stays as written.
|
|
45
66
|
|
|
46
|
-
To opt out for an existing database with quoted camelCase columns or models with snake_case fields, use `PgClient.layer(clientOptions(options, { snakeCase: false }))`. Existing native clients are unchanged until configured with this helper. Native `transformQueryNames` and `transformResultNames` supplied in `options` take precedence. For `
|
|
67
|
+
To opt out for an existing database with quoted camelCase columns or models with snake_case fields, use `PgClient.layer(clientOptions(options, { snakeCase: false }))`. Existing native clients are unchanged until configured with this helper. Native `transformQueryNames` and `transformResultNames` supplied in `options` take precedence. For `appHost` or `appDatabase` from `@shivaedev/server/appHost.ts`, use `clientOptions` on their `config` object; the host forwards those native transforms to its client.
|
|
47
68
|
|
|
48
69
|
### 1. Once per model: fields and writable variants
|
|
49
70
|
|
|
@@ -53,20 +74,20 @@ import { Model } from "effect/schema";
|
|
|
53
74
|
import { makeRepository } from "@shivaedev/sql/repository.ts";
|
|
54
75
|
|
|
55
76
|
class InvoiceLine extends Model.Class<InvoiceLine>("InvoiceLine")({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
amount: Schema.NumberFromString,
|
|
78
|
+
id: Model.Field({
|
|
79
|
+
select: Schema.Number,
|
|
80
|
+
update: Schema.Number,
|
|
81
|
+
json: Schema.Number,
|
|
82
|
+
}),
|
|
83
|
+
name: Schema.String,
|
|
84
|
+
note: Schema.NullOr(Schema.String),
|
|
64
85
|
}) {}
|
|
65
86
|
|
|
66
87
|
const makeInvoiceLines = makeRepository(InvoiceLine, {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
tableName: "invoice_line",
|
|
89
|
+
idColumn: "id",
|
|
90
|
+
spanPrefix: "InvoiceLine",
|
|
70
91
|
});
|
|
71
92
|
```
|
|
72
93
|
|
|
@@ -78,8 +99,8 @@ A generated creation time can have a different database and JSON representation:
|
|
|
78
99
|
|
|
79
100
|
```ts
|
|
80
101
|
const createdAt = Model.Field({
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
select: Schema.Date,
|
|
103
|
+
json: Schema.DateFromString,
|
|
83
104
|
});
|
|
84
105
|
```
|
|
85
106
|
|
|
@@ -93,17 +114,15 @@ import { Result, SchemaGetter, SchemaParser } from "effect";
|
|
|
93
114
|
|
|
94
115
|
const Integers = Schema.Array(Schema.Int);
|
|
95
116
|
const StoredIntegers = Schema.Unknown.pipe(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
encode: SchemaGetter.transform((values) => Result.getOrThrow(PgTypes.array(values, PgTypes.OID.int4))),
|
|
101
|
-
}),
|
|
117
|
+
Schema.decodeTo(Integers, {
|
|
118
|
+
decode: SchemaGetter.transformEffect((value) => SchemaParser.decodeUnknownEffect(Integers)(PgTypes.isParameter(value) ? value.value : value)),
|
|
119
|
+
encode: SchemaGetter.transform((values) => Result.getOrThrow(PgTypes.array(values, PgTypes.OID.int4))),
|
|
120
|
+
}),
|
|
102
121
|
);
|
|
103
122
|
|
|
104
123
|
class Sample extends Model.Class<Sample>("Sample")({
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
id: Schema.Int,
|
|
125
|
+
values: Model.Field({ insert: StoredIntegers, select: StoredIntegers, update: StoredIntegers, json: Integers }),
|
|
107
126
|
}) {}
|
|
108
127
|
```
|
|
109
128
|
|
|
@@ -113,20 +132,20 @@ The decoded value is an integer array in every variant. The database variants en
|
|
|
113
132
|
|
|
114
133
|
```ts
|
|
115
134
|
const reviseInvoiceLine = Effect.gen(function* () {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
const lines = yield* makeInvoiceLines;
|
|
136
|
+
const inserted = yield* lines.insert({
|
|
137
|
+
amount: 52,
|
|
138
|
+
name: "Consulting",
|
|
139
|
+
note: null,
|
|
140
|
+
});
|
|
141
|
+
const updated = yield* lines.update({
|
|
142
|
+
...inserted,
|
|
143
|
+
amount: 55,
|
|
144
|
+
name: "Onsite consulting",
|
|
145
|
+
});
|
|
146
|
+
const found = yield* lines.findById(updated.id);
|
|
147
|
+
yield* lines.delete(found.id);
|
|
148
|
+
return yield* lines.findMany({ where: { id: found.id } });
|
|
130
149
|
});
|
|
131
150
|
```
|
|
132
151
|
|
|
@@ -144,15 +163,15 @@ For lists, choose the fields the caller needs:
|
|
|
144
163
|
|
|
145
164
|
```ts
|
|
146
165
|
const listInvoiceLines = (name: string | undefined) =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const lines = yield* makeInvoiceLines;
|
|
168
|
+
return yield* lines.findMany({
|
|
169
|
+
where: { amount: 52, name, note: null },
|
|
170
|
+
select: ["name", "amount"],
|
|
171
|
+
orderBy: { field: "name", direction: "desc" },
|
|
172
|
+
limit: 20,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
156
175
|
```
|
|
157
176
|
|
|
158
177
|
Filter entries are joined with `AND`. A field value uses equality; a null filter matches SQL `NULL`, and an omitted or `undefined` value leaves the field unconstrained. Filters use decoded field values, so `amount` takes a number. Selected results are decoded records with the selected fields' types; this function returns `Array<{ readonly name: string; readonly amount: number }>`.
|
|
@@ -267,34 +286,30 @@ Here `archive` is another unversioned `InvoiceLine` repository with its own tabl
|
|
|
267
286
|
|
|
268
287
|
A repository configured with `conflictColumns` also exposes `insertFromSelectOrIgnore(source, columns)`. It uses the same conflict columns and optional `conflictWhere` predicate as `insertOrIgnore`, skips existing keys, and returns only inserted rows. An empty source returns `[]`. Both writes join `transact` and roll back with it. Source filter encoding services and target result decoding services remain in the write's Effect environment.
|
|
269
288
|
|
|
270
|
-
|
|
271
289
|
### Composite keys and idempotent writes
|
|
272
290
|
|
|
273
291
|
A generated app can ingest the same reading more than once. Declare the table's primary key with an `idColumn` tuple and its unique conflict key with `conflictColumns`:
|
|
274
292
|
|
|
275
293
|
```ts
|
|
276
294
|
class Reading extends Model.Class<Reading>("Reading")({
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
295
|
+
source: Schema.String,
|
|
296
|
+
sequence: Schema.NumberFromString,
|
|
297
|
+
amount: Schema.NumberFromString,
|
|
298
|
+
label: Schema.String,
|
|
281
299
|
}) {}
|
|
282
300
|
|
|
283
301
|
const ingest = Effect.gen(function* () {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
);
|
|
296
|
-
const found = yield* readings.findById({ source: "meter", sequence: 1 });
|
|
297
|
-
return { inserted, saved, found };
|
|
302
|
+
const readings = yield* makeRepository(Reading, {
|
|
303
|
+
tableName: "readings",
|
|
304
|
+
idColumn: ["source", "sequence"],
|
|
305
|
+
conflictColumns: ["source", "sequence"],
|
|
306
|
+
spanPrefix: "Reading",
|
|
307
|
+
});
|
|
308
|
+
const input = { source: "meter", sequence: 1, amount: 52, label: "Imported" };
|
|
309
|
+
const inserted = yield* readings.insertOrIgnore(input);
|
|
310
|
+
const saved = yield* readings.upsert({ ...input, amount: 60 }, { updateColumns: ["amount"] });
|
|
311
|
+
const found = yield* readings.findById({ source: "meter", sequence: 1 });
|
|
312
|
+
return { inserted, saved, found };
|
|
298
313
|
});
|
|
299
314
|
```
|
|
300
315
|
|
|
@@ -302,12 +317,12 @@ The application creates `readings` with `PRIMARY KEY (source, sequence)`; `seque
|
|
|
302
317
|
|
|
303
318
|
`conflictColumns` is a nonempty tuple of inserted model fields backed by a database unique constraint or primary key. It can differ from the primary key, including a unique external key beside a generated ID. Declaring it adds these methods:
|
|
304
319
|
|
|
305
|
-
| Method
|
|
306
|
-
|
|
|
307
|
-
| `insertOrIgnore`
|
|
308
|
-
| `upsert`
|
|
309
|
-
| `insertManyOrIgnore` | Readonly array of model insert inputs
|
|
310
|
-
| `upsertMany`
|
|
320
|
+
| Method | Input | Result |
|
|
321
|
+
| -------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
322
|
+
| `insertOrIgnore` | Model insert input | `Option.some` of the decoded inserted model, or `Option.none` when the declared key conflicts. |
|
|
323
|
+
| `upsert` | Model insert input and `{ updateColumns: [...] }` | `{ row, inserted }`, with the decoded model and a boolean identifying an insert. |
|
|
324
|
+
| `insertManyOrIgnore` | Readonly array of model insert inputs | Decoded inserted models; conflicting rows are omitted. |
|
|
325
|
+
| `upsertMany` | Readonly array of model insert inputs and `{ updateColumns: [...] }` | An array of `{ row, inserted }` results. |
|
|
311
326
|
|
|
312
327
|
All four methods encode with the model's insert schema and retain its codec services. `upsert` and `upsertMany` require a nonempty list of inserted, updateable fields; primary-key, conflict-key and generated fields are excluded. Only those columns take their incoming encoded values on conflict. Other stored values stay intact. A selected optional field must be present in every encoded input. Unknown, empty, protected or absent update columns fail with `SchemaError`. A violation of another unique constraint remains a native `SqlError`.
|
|
313
328
|
|
|
@@ -369,27 +384,27 @@ Declare a numeric version field in the model's select and update variants, leave
|
|
|
369
384
|
|
|
370
385
|
```ts
|
|
371
386
|
class Document extends Model.Class<Document>("Document")({
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
387
|
+
id: Schema.String,
|
|
388
|
+
ownerId: Schema.String,
|
|
389
|
+
name: Schema.String,
|
|
390
|
+
lockVersion: Model.Field({ select: Schema.Number, update: Schema.Number, json: Schema.Number }),
|
|
376
391
|
}) {}
|
|
377
392
|
|
|
378
393
|
const makeDocuments = makeRepository(Document, {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
394
|
+
tableName: "documents",
|
|
395
|
+
idColumn: ["id", "ownerId"],
|
|
396
|
+
conflictColumns: ["id"],
|
|
397
|
+
spanPrefix: "Document",
|
|
398
|
+
versionColumn: "lockVersion",
|
|
384
399
|
});
|
|
385
400
|
|
|
386
401
|
const reviseDocument = Effect.gen(function* () {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
402
|
+
const documents = yield* makeDocuments;
|
|
403
|
+
const key = { id: `${yield* Random.nextInt}:${yield* Random.nextInt}`, ownerId: "current-owner" };
|
|
404
|
+
yield* documents.insertOrIgnore({ ...key, name: "Draft" });
|
|
405
|
+
const original = yield* documents.findById(key);
|
|
406
|
+
const saved = yield* documents.update({ ...original, name: "Published" });
|
|
407
|
+
yield* documents.delete(key, saved.lockVersion);
|
|
393
408
|
});
|
|
394
409
|
```
|
|
395
410
|
|
|
@@ -408,19 +423,19 @@ import { Data } from "effect";
|
|
|
408
423
|
import { transact } from "@shivaedev/sql/transact.ts";
|
|
409
424
|
|
|
410
425
|
class StorageUnavailable extends Data.TaggedError("StorageUnavailable")<{
|
|
411
|
-
|
|
426
|
+
readonly reason: string;
|
|
412
427
|
}> {}
|
|
413
428
|
|
|
414
429
|
const saveInvoiceLine = (input: typeof InvoiceLine.update.Type) =>
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
430
|
+
Effect.gen(function* () {
|
|
431
|
+
const lines = yield* makeInvoiceLines;
|
|
432
|
+
const saved = yield* lines.update(input);
|
|
433
|
+
return saved;
|
|
434
|
+
}).pipe(
|
|
435
|
+
transact({
|
|
436
|
+
onSqlError: (error) => new StorageUnavailable({ reason: error.message }),
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
424
439
|
```
|
|
425
440
|
|
|
426
441
|
`transact` wraps native `sql.withTransaction`. It maps typed `SqlError` failures through `onSqlError` and keeps other typed failures and service requirements. `invalidateOnCommit` records derived reader keys that a repository or command declaration does not already name; after the owning outermost commit, they are deduplicated and passed to native `Reactivity.invalidate` once. The record `{ invoiceLines: [1] }` marks `"invoiceLines"` and `"invoiceLines:1"`. An array such as `["invoiceLines:1"]` marks only those keys.
|
|
@@ -432,10 +447,10 @@ import { bucket } from "@shivaedev/changes/buckets.ts";
|
|
|
432
447
|
import { collection, invalidationKeys } from "@shivaedev/changes/keys.ts";
|
|
433
448
|
|
|
434
449
|
class Note extends Model.Class<Note>("Note")({
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
450
|
+
id: Schema.Number,
|
|
451
|
+
listId: bucket(Schema.Number),
|
|
452
|
+
owner: bucket(Schema.String),
|
|
453
|
+
title: Schema.String,
|
|
439
454
|
}) {}
|
|
440
455
|
|
|
441
456
|
const notes = collection("notes", Note.fields.id, { listId: Note.fields.listId, owner: Note.fields.owner });
|
|
@@ -445,8 +460,9 @@ Use the repository's table name as the collection name in query read keys. Every
|
|
|
445
460
|
|
|
446
461
|
Bucket writes require PostgreSQL 18 or later: native `RETURNING old` supplies old values atomically, including for conflict updates. Repositories without bucket fields keep their existing SQL dialect support. Bulk writes keep every record and bucket key; the PostgreSQL publisher splits large batches into NOTIFY payloads.
|
|
447
462
|
|
|
448
|
-
|
|
463
|
+
Record and bucket keys that must commit atomically with a write come from this model and repository path. A command's `invalidates(payload, result, viewer)` runs after its handler returns and uses a separate publication transaction. Failed publication retries twice with 50 ms and 100 ms backoffs. If those attempts fail, `postgresChanges` resynchronizes local live streams and broadcasts Resync on its change channel. If PostgreSQL also rejects the broadcast or cannot be reached, local streams still resynchronize, but healthy listeners on other hosts can miss the write until they reconnect or otherwise reconcile. The failure is logged; NOTIFY provides no durable replay.
|
|
449
464
|
|
|
465
|
+
The transaction rules follow the database owner:
|
|
450
466
|
- A typed failure or interruption before commit rolls back and announces nothing. A failed deferred `COMMIT` fails through `onSqlError` and also announces nothing.
|
|
451
467
|
- A nested `transact` on the same client uses a native savepoint. Its successful keys join the outer set; a rolled-back savepoint's keys are discarded.
|
|
452
468
|
- A transaction on a second client commits independently. Its keys publish when that client commits, even if the enclosing client's transaction later rolls back.
|
|
@@ -466,14 +482,15 @@ Serialize cooperating jobs inside `transact`:
|
|
|
466
482
|
```ts
|
|
467
483
|
import { advisoryXactLock } from "@shivaedev/sql/advisoryXactLock.ts";
|
|
468
484
|
|
|
469
|
-
const serializeInvoiceJob = (input: typeof InvoiceLine.update.Type) =>
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
)
|
|
485
|
+
const serializeInvoiceJob = (input: typeof InvoiceLine.update.Type) =>
|
|
486
|
+
Effect.gen(function* () {
|
|
487
|
+
yield* advisoryXactLock("jobs:invoice-import");
|
|
488
|
+
yield* saveInvoiceLine(input);
|
|
489
|
+
}).pipe(
|
|
490
|
+
transact({
|
|
491
|
+
onSqlError: (error) => new StorageUnavailable({ reason: error.message }),
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
477
494
|
```
|
|
478
495
|
|
|
479
496
|
`advisoryXactLock(key: bigint | string)` waits for PostgreSQL's `pg_advisory_xact_lock` on the current client's transaction. A bigint is passed as a signed 64-bit PostgreSQL key without converting it to a JavaScript number. A string uses PostgreSQL's `hashtextextended(key, 0)` as its bigint key; use the same string in cooperating callers. Hash collisions share a lock. Different keys proceed independently. The lock lasts until commit or rollback, including rollback when the holding fiber is interrupted. Outside a transaction, the function fails with typed `AdvisoryLockOutsideTransaction` from the same module. SQL failures remain typed `SqlError` and follow the surrounding `transact` mapper.
|
|
@@ -487,8 +504,8 @@ import { Effect } from "effect";
|
|
|
487
504
|
import { SqlClient } from "effect/sql";
|
|
488
505
|
|
|
489
506
|
export default Effect.gen(function* () {
|
|
490
|
-
|
|
491
|
-
|
|
507
|
+
const sql = yield* SqlClient.SqlClient;
|
|
508
|
+
yield* sql`create table orders (name text primary key)`;
|
|
492
509
|
});
|
|
493
510
|
```
|
|
494
511
|
|
|
@@ -501,9 +518,9 @@ import { migrationsFromDirectory } from "@shivaedev/sql/migrationsFromDirectory.
|
|
|
501
518
|
import { migratePostgres } from "@shivaedev/sql/migrations.ts";
|
|
502
519
|
|
|
503
520
|
const migrateOrders = migratePostgres({
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
521
|
+
table: "app_migrations",
|
|
522
|
+
lockTimeout: "5 seconds",
|
|
523
|
+
loader: migrationsFromDirectory(process.cwd()).pipe(Effect.provide(NodeServices.layer)),
|
|
507
524
|
});
|
|
508
525
|
```
|
|
509
526
|
|
|
@@ -517,18 +534,17 @@ Use sequential numbers, not timestamps. Effect's Migrator skips any migration wh
|
|
|
517
534
|
|
|
518
535
|
`migratePostgres` takes a transaction-scoped advisory lock before creating the ledger and running native Migrator. Concurrent runners on a fresh or existing ledger wait, then see the committed ledger and avoid repeating the migrations. Every runner of that ledger must use this helper and the same ledger name. The required `lockTimeout` bounds PostgreSQL lock waits; `Duration.infinity` sets the transaction's lock timeout to zero. Choose deployment and execution timeouts in the application.
|
|
519
536
|
|
|
520
|
-
A failed batch rolls back its DDL, data and ledger entries, retaining previously committed migrations. A failed first batch also rolls back the bootstrapped ledger. A lock timeout fails with typed `SqlError` and reason `LockTimeoutError`; a migration-body SQL failure is a native `MigrationError
|
|
537
|
+
A failed batch rolls back its DDL, data and ledger entries, retaining previously committed migrations. A failed first batch also rolls back the bootstrapped ledger. A lock timeout fails with typed `SqlError` and reason `LockTimeoutError`; a migration-body SQL failure is a typed native `MigrationError`. Inspect startup completion with `Effect.exit` when both typed failures and defects matter. Keep nontransactional DDL in schema components; external side effects, crash recovery and deployment compatibility need their own migration policy.
|
|
521
538
|
|
|
522
|
-
Schema components describe storage owned by another engine alongside the numbered `loader`: pass `components: [{ name, apply, pending }]` to `migratePostgres` and `assertPostgresMigrations`. `apply` runs after the ledger transaction commits, outside that transaction, on every migration invocation; it must support repeated execution. `pending` returns a description when work remains and `undefined` when current. Components are never recorded as numbered ledger entries. Component failures
|
|
539
|
+
Schema components describe storage owned by another engine alongside the numbered `loader`: pass `components: [{ name, apply, pending }]` to `migratePostgres` and `assertPostgresMigrations`. `apply` runs after the ledger transaction commits, outside that transaction, on every migration invocation; it must support repeated execution. `pending` returns a description when work remains and `undefined` when current. Components are never recorded as numbered ledger entries. Component failures reach callers unchanged, including their original tags and causes, and are logged with the component name; their failure leaves the application ledger committed. Migration and readiness functions infer component error types. An explicitly annotated `PostgresMigrationOptions<R, E>` uses its second parameter for those errors; omitting it describes components that cannot fail.
|
|
523
540
|
|
|
524
541
|
For processes whose deployment owns migration execution, call `assertPostgresMigrations({ loader, table?, components? })` from `@shivaedev/sql/assertPostgresMigrations.ts` before starting services. It checks the native loader’s required IDs and names against the ledger without running migration bodies or creating a ledger. A missing ledger, pending migration or mismatched name fails with native `MigrationError` (`kind: "BadState"`); database failures stay typed `SqlError`. Later ledger entries are allowed so an older application version can start during a rolling deployment. Pending ledger IDs and component names and details appear together in the error. This checks recorded migrations and component readouts, not physical schema drift.
|
|
525
542
|
|
|
526
|
-
For a structured readiness readout, use `pendingPostgresMigrations({ loader, table?, components? })` from `@shivaedev/sql/pendingPostgresMigrations.ts`. It returns `{ migrations: [{ id, name }], components: [{ name, detail }] }` for pending work, with both arrays empty when current. It reads the same ledger and component checks as `assertPostgresMigrations` without running migrations.
|
|
543
|
+
For a structured readiness readout, use `pendingPostgresMigrations({ loader, table?, components? })` from `@shivaedev/sql/pendingPostgresMigrations.ts`. It returns `{ migrations: [{ id, name }], components: [{ name, detail }] }` for pending work, with both arrays empty when current. It reads the same ledger and component checks as `assertPostgresMigrations` without running migrations. Malformed ledger and release rows fail with `SchemaError`.
|
|
527
544
|
|
|
528
545
|
Pass an optional `release` string to `migratePostgres` to record the owning release after all ledger migrations and schema components succeed. The final `postgresRelease(release, table?)` schema component owns a one-row `<ledger>_release` table with typed `release` and UTC `recorded_at timestamptz` columns, plus a `<ledger>_workers` table with typed process identity, release and UTC heartbeat columns. `readPostgresRelease(table?)` from `postgresRelease.ts` returns the recorded release and `recordedAt` Date, or `undefined` for a missing marker. `pendingPostgresMigrations({ loader, release, table? })` reports a missing or different release as a pending component. Without `release`, migration and readiness calls neither write nor read these tables.
|
|
529
546
|
|
|
530
|
-
|
|
531
|
-
|
|
547
|
+
`appDatabase` from `@shivaedev/server/appHost.ts` supplies the optional `WorkerRelease` Context service in migration mode `"wait"` with a release. Startup waits for that marker within `waitTimeout`; subsequent checks run every `pollInterval`. Each worker database Layer claims a process row only when its release owns the marker and no live row of another release remains. Heartbeats continue every poll while jobs drain, including after a connection blip. Each failed heartbeat or ownership poll logs its cause and retries three times, one quarter of a polling interval apart; exhausted retries close the worker scope with the failure cause. Completed drain and clean shutdown remove the row. Rows older than five polling intervals count as gone, bounding takeover after a crash (five seconds at the default one-second poll). Reserve SQL pool capacity for heartbeats alongside active jobs and the change listener. Release equality decides ownership, so rolling back the marker resumes the earlier worker. `@shivaedev/jobs` reads the service automatically; applications do not connect a signal themselves. Web's `"check"` mode ignores the marker.
|
|
532
548
|
|
|
533
549
|
### PostgreSQL delivery between processes
|
|
534
550
|
|
|
@@ -536,41 +552,44 @@ Server's `appDatabase` supplies the optional `WorkerRelease` Context service in
|
|
|
536
552
|
|
|
537
553
|
```ts
|
|
538
554
|
const delivery = postgresChanges({
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
555
|
+
channel: "application_changes",
|
|
556
|
+
client: pg,
|
|
557
|
+
resync: hints.resync,
|
|
542
558
|
});
|
|
543
559
|
```
|
|
544
560
|
|
|
545
|
-
`pg` is the application's `@effect/sql-pg` client; the binding accepts its native SQL and `listen` methods without creating another pool.
|
|
561
|
+
`pg` is the application's `@effect/sql-pg` client; the binding accepts its native SQL and `listen` methods without creating another pool. Optional `listenerClient` supplies a different native client for LISTEN; the application client still owns write notifications and heartbeat publication. Both clients must reach the same database. LISTEN requires a direct or session-mode connection. With one shared client, reserve one session connection for LISTEN and another for writes and heartbeats; use a pool client with at least two connections. With separate clients, the listener needs one connection of its own. Compose `delivery` into the Layer that provides feature services using `transact`, alongside the existing SQL and Reactivity services. The [reference server](https://github.com/ShivaeDev/platform/blob/main/packages/react/src/test-support/postgres-example/server.ts) shows the complete composition. The binding sends `select pg_notify(...)` through the writing transaction's `SqlClient`, so a rollback wakes no process. Its transaction sink leaves delivery to the listener, including in the writing process. Unframed `invalidateOnCommit` calls retain local invalidation; cross-process writes must use `transact`.
|
|
546
562
|
|
|
547
|
-
`PostgresChangesOptions` takes `channel`, `client`, `resync`, and optional `heartbeatInterval`, `heartbeatTimeout` and `retryDelay` Effect durations. The defaults are 5 seconds, 10 seconds and 1 second. Acquisition waits for a heartbeat received through
|
|
563
|
+
`PostgresChangesOptions` takes `channel`, `client`, `resync`, optional `listenerClient` and `connectionSetting` (a diagnostic label, default `"client"`), and optional `heartbeatInterval`, `heartbeatTimeout` and `retryDelay` Effect durations. The defaults are 5 seconds, 10 seconds and 1 second. Acquisition waits for LISTEN and then a heartbeat received through it, each within `heartbeatTimeout`. A deadline without a heartbeat fails startup with `PostgresListenerError` from `@shivaedev/sql/PostgresListenerError.ts`, whose `connectionSetting` and message identify the connection to fix. Native SQL failures still fail startup with `SqlError`. Established listeners reconnect after a loss. Loss and reconnect failures log their causes. A missing heartbeat or a stream failure resyncs streams before listener cleanup; a successful reconnection resyncs again. Listener cleanup runs in a parallel sub-scope bounded by `heartbeatTimeout`, so stalled listener cleanup cannot delay native connection release, a fresh subscription or scope shutdown. The heartbeat detects a listener that stops delivering without failing. `resync` is an infallible `Effect.Effect<number>` returning the number of open live streams it marked for reconciliation. `makeLiveServer.resync` provides this count and stops counting a stream when its scope closes. Direct bindings with another stream owner must return that owner's count. Provide the Layer for the process lifetime so scope release closes the listener.
|
|
548
564
|
|
|
549
565
|
NOTIFY is a wake-up signal, not a durable log. Transaction payloads contain only string or finite-number invalidation keys, with no row values. PostgreSQL requires each payload to be smaller than 8,000 bytes in the default configuration. The binding splits JSON arrays by their UTF-8 byte length; one key that cannot fit, or a nonserializable key type, fails with `SqlError` before commit and rolls back the write. Heartbeats use a separate control payload on the same channel. Applications choose a channel shared only by their cooperating processes. LISTEN requires a session connection; PgBouncer transaction pooling needs another delivery adapter. Missed notifications are reconciled by querying again on resync, with no replay guarantee.
|
|
550
566
|
|
|
551
567
|
### API
|
|
552
568
|
|
|
553
569
|
Import the module that defines the name; there is no root entry.
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
570
|
+
- `@shivaedev/sql/clientOptions.ts`: `clientOptions(options, { snakeCase? })`, `SqlNameTransforms` — Add default native name transforms to driver or app host options; opt out with `snakeCase: false`.
|
|
571
|
+
- `@shivaedev/sql/repository.ts`: `makeRepository(model, options)` — `tableName`, scalar or composite `idColumn`, `spanPrefix`, optional `versionColumn`, `conflictColumns` and `conflictWhere`; requires native `SqlClient`.
|
|
572
|
+
- `@shivaedev/sql/repository.ts`: `OptimisticConcurrencyError` — Typed stale or missing-row failure for versioned writes, with `tableName`, `id`, `expectedVersion`, `reason: "missing" | "stale"`.
|
|
573
|
+
- `@shivaedev/sql/tupleIn.ts`: `tupleIn(model, fields, tuples)` — Filter entry matching two or more fields together against a list of tuples, for `where` in reads and unversioned deletes.
|
|
574
|
+
- `@shivaedev/sql/upsertResult.ts`: `UpsertRow` — The `{ row, inserted }` result of an unconditional upsert.
|
|
575
|
+
- `@shivaedev/sql/conditionalUpsert.ts`: `UpsertFilter`, `UpsertResult` — The `updateWhere` filter shape and the tagged `Inserted`, `Updated` or `Unchanged` result of a conditional upsert.
|
|
576
|
+
- `@shivaedev/sql/mapSqlError.ts`: `mapSqlError({ constraints, fallback })` — Mapper from named constraint violations to application errors, with a fallback for every other `SqlError`.
|
|
577
|
+
- `@shivaedev/sql/transact.ts`: `transact(effect, options)` or `effect.pipe(transact(options))` — Required `onSqlError` mapper; requires `SqlClient` and `Reactivity`.
|
|
578
|
+
- `@shivaedev/sql/transact.ts`: `invalidateOnCommit(keys)` — Records keys for its client, or invalidates immediately outside a transaction.
|
|
579
|
+
- `@shivaedev/sql/advisoryXactLock.ts`: `advisoryXactLock(key)`, `AdvisoryLockOutsideTransaction` — PostgreSQL transaction lock on a bigint or string hashed with `hashtextextended(key, 0)`; requires an active transaction on the current `SqlClient`.
|
|
580
|
+
- `@shivaedev/sql/migrationConvention.ts`: `MIGRATIONS_DIRECTORY`, `MIGRATION_FILE_PATTERN` — Application folder and four-digit sequential snake_case TypeScript filename pattern.
|
|
581
|
+
- `@shivaedev/sql/migrationsFromDirectory.ts`: `migrationsFromDirectory(root)` — Discover migrations under the application root with native `FileSystem` and `Path`.
|
|
582
|
+
- `@shivaedev/sql/migrations.ts`: `migratePostgres(options)` — Native loader, required `lockTimeout`, optional `components`, `release` and ledger `table`, default `effect_sql_migrations`.
|
|
583
|
+
- `@shivaedev/sql/assertPostgresMigrations.ts`: `assertPostgresMigrations({ loader, table?, components? })` — Read-only readiness check of required migration IDs and names in the native ledger and registered schema components.
|
|
584
|
+
- `@shivaedev/sql/pendingPostgresMigrations.ts`: `pendingPostgresMigrations({ loader, table?, components? })` — Pending ledger migrations as ID and name, and pending schema components as name and detail.
|
|
585
|
+
- `@shivaedev/sql/postgresRelease.ts`: `postgresRelease(release, table?)`, `readPostgresRelease(table?)` — Release marker schema component and its reader.
|
|
586
|
+
- `@shivaedev/sql/postgresChanges.ts`: `postgresChanges(options)` — Scoped PostgreSQL LISTEN/NOTIFY Layer for commit-bound invalidation.
|
|
587
|
+
- `@shivaedev/sql/testing.ts`: `withChangeCoverage(body, { covers })`, `expectPublished(keys)`, `UnannouncedWrites`, `PublishedKeysMismatch` — Test assertions that every write announced its keys and that the expected keys were published.
|
|
569
588
|
|
|
570
589
|
### Install and database setup
|
|
571
590
|
|
|
572
591
|
```sh
|
|
573
|
-
pnpm add @shivaedev/sql effect
|
|
592
|
+
pnpm add @shivaedev/sql effect @effect/sql-pg
|
|
574
593
|
```
|
|
575
594
|
|
|
576
595
|
`effect` is a peer. Install the native driver your application uses; this command chooses PostgreSQL. Provide its layer, and provide native Reactivity when using `transact` or `invalidateOnCommit`:
|
|
@@ -581,16 +600,13 @@ import { Layer, Redacted } from "effect";
|
|
|
581
600
|
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
582
601
|
import { clientOptions } from "@shivaedev/sql/clientOptions.ts";
|
|
583
602
|
|
|
584
|
-
const database = Layer.merge(
|
|
585
|
-
PgClient.layer(clientOptions({ url: Redacted.make("postgresql://localhost/example") })),
|
|
586
|
-
Reactivity.layer,
|
|
587
|
-
);
|
|
603
|
+
const database = Layer.merge(PgClient.layer(clientOptions({ url: Redacted.make("postgresql://localhost/example") })), Reactivity.layer);
|
|
588
604
|
|
|
589
605
|
const save = saveInvoiceLine({
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
606
|
+
id: 1,
|
|
607
|
+
name: "Consulting",
|
|
608
|
+
amount: 52,
|
|
609
|
+
note: null,
|
|
594
610
|
}).pipe(Effect.provide(database));
|
|
595
611
|
```
|
|
596
612
|
|
|
@@ -607,22 +623,20 @@ import { mapSqlError } from "@shivaedev/sql/mapSqlError.ts";
|
|
|
607
623
|
import { transact } from "@shivaedev/sql/transact.ts";
|
|
608
624
|
|
|
609
625
|
const onSqlError = mapSqlError({
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
626
|
+
constraints: {
|
|
627
|
+
"invoice_line_name_key": () => new NameTaken(),
|
|
628
|
+
"invoice_line_invoice_fkey": () => new InvoiceMissing(),
|
|
629
|
+
"invoice_line_amount_check": () => new InvalidAmount(),
|
|
630
|
+
},
|
|
631
|
+
fallback: (error) => new StorageUnavailable({ reason: error.message }),
|
|
616
632
|
});
|
|
617
633
|
|
|
618
|
-
const insertNamedLine = lines.insert({ amount: 52, name: "Consulting", note: null }).pipe(
|
|
619
|
-
transact({ onSqlError }),
|
|
620
|
-
);
|
|
634
|
+
const insertNamedLine = lines.insert({ amount: 52, name: "Consulting", note: null }).pipe(transact({ onSqlError }));
|
|
621
635
|
```
|
|
622
636
|
|
|
623
637
|
The mapper handles named unique violations and PostgreSQL foreign-key and check violations. Every other `SqlError`, including unknown constraints, goes to `fallback`. Each callback receives the original error. The inferred error type contains exactly the mapped errors and the fallback; `transact` also preserves other typed failures from the operation. Constraint names belong to the application's migration; error types and presentation belong to the application.
|
|
624
638
|
|
|
625
|
-
Recover outside `transact` so earlier writes roll back before handling a mapped domain error. A losing unique-key insert can then catch `NameTaken` and read the winner's row without a private transaction error. See the [constraint-failures guide](
|
|
639
|
+
Recover outside `transact` so earlier writes roll back before handling a mapped domain error. A losing unique-key insert can then catch `NameTaken` and read the winner's row without a private transaction error. See the [constraint-failures guide](https://github.com/ShivaeDev/platform/blob/main/docs/framework/constraint-errors.md).
|
|
626
640
|
|
|
627
641
|
For the native PostgreSQL driver's default representations, use precise numeric strings with `Schema.BigDecimalFromString`, `int8` values with `Schema.BigInt`, `date` values as `YYYY-MM-DD` strings with `Schema.String`, instants with `Schema.Date`, nullable text and generated values. A persisted value that violates its schema fails decoding; a write and read inside the enclosing transaction roll back together when that failure escapes.
|
|
628
642
|
|