@shivaedev/sql 0.24.0 → 0.25.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 +15 -0
- package/README.md +149 -156
- package/dist/transact.js +1 -1
- package/dist/transact.js.map +1 -1
- package/package.json +12 -11
- package/src/transact.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0 - 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Require the Effect 4.0.6 stack.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Publish committed SQL invalidations immediately for their owning database, including when another database has an open transaction.
|
|
12
|
+
|
|
13
|
+
### Breaking
|
|
14
|
+
|
|
15
|
+
- `@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.
|
|
16
|
+
- `@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.
|
|
17
|
+
|
|
3
18
|
## 0.24.0 - 2026-10-28
|
|
4
19
|
|
|
5
20
|
### Added
|
package/README.md
CHANGED
|
@@ -36,14 +36,16 @@ import * as PgClient from "@effect/sql-pg/PgClient";
|
|
|
36
36
|
import { Redacted } from "effect";
|
|
37
37
|
import { clientOptions } from "@shivaedev/sql/clientOptions.ts";
|
|
38
38
|
|
|
39
|
-
const database = PgClient.layer(
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const database = PgClient.layer(
|
|
40
|
+
clientOptions({
|
|
41
|
+
url: Redacted.make("postgresql://localhost/example"),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
42
44
|
```
|
|
43
45
|
|
|
44
46
|
`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
47
|
|
|
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 `
|
|
48
|
+
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
49
|
|
|
48
50
|
### 1. Once per model: fields and writable variants
|
|
49
51
|
|
|
@@ -53,20 +55,20 @@ import { Model } from "effect/schema";
|
|
|
53
55
|
import { makeRepository } from "@shivaedev/sql/repository.ts";
|
|
54
56
|
|
|
55
57
|
class InvoiceLine extends Model.Class<InvoiceLine>("InvoiceLine")({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
amount: Schema.NumberFromString,
|
|
59
|
+
id: Model.Field({
|
|
60
|
+
select: Schema.Number,
|
|
61
|
+
update: Schema.Number,
|
|
62
|
+
json: Schema.Number,
|
|
63
|
+
}),
|
|
64
|
+
name: Schema.String,
|
|
65
|
+
note: Schema.NullOr(Schema.String),
|
|
64
66
|
}) {}
|
|
65
67
|
|
|
66
68
|
const makeInvoiceLines = makeRepository(InvoiceLine, {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
tableName: "invoice_line",
|
|
70
|
+
idColumn: "id",
|
|
71
|
+
spanPrefix: "InvoiceLine",
|
|
70
72
|
});
|
|
71
73
|
```
|
|
72
74
|
|
|
@@ -78,8 +80,8 @@ A generated creation time can have a different database and JSON representation:
|
|
|
78
80
|
|
|
79
81
|
```ts
|
|
80
82
|
const createdAt = Model.Field({
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
select: Schema.Date,
|
|
84
|
+
json: Schema.DateFromString,
|
|
83
85
|
});
|
|
84
86
|
```
|
|
85
87
|
|
|
@@ -93,17 +95,15 @@ import { Result, SchemaGetter, SchemaParser } from "effect";
|
|
|
93
95
|
|
|
94
96
|
const Integers = Schema.Array(Schema.Int);
|
|
95
97
|
const StoredIntegers = Schema.Unknown.pipe(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
encode: SchemaGetter.transform((values) => Result.getOrThrow(PgTypes.array(values, PgTypes.OID.int4))),
|
|
101
|
-
}),
|
|
98
|
+
Schema.decodeTo(Integers, {
|
|
99
|
+
decode: SchemaGetter.transformEffect((value) => SchemaParser.decodeUnknownEffect(Integers)(PgTypes.isParameter(value) ? value.value : value)),
|
|
100
|
+
encode: SchemaGetter.transform((values) => Result.getOrThrow(PgTypes.array(values, PgTypes.OID.int4))),
|
|
101
|
+
}),
|
|
102
102
|
);
|
|
103
103
|
|
|
104
104
|
class Sample extends Model.Class<Sample>("Sample")({
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
id: Schema.Int,
|
|
106
|
+
values: Model.Field({ insert: StoredIntegers, select: StoredIntegers, update: StoredIntegers, json: Integers }),
|
|
107
107
|
}) {}
|
|
108
108
|
```
|
|
109
109
|
|
|
@@ -113,20 +113,20 @@ The decoded value is an integer array in every variant. The database variants en
|
|
|
113
113
|
|
|
114
114
|
```ts
|
|
115
115
|
const reviseInvoiceLine = Effect.gen(function* () {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
116
|
+
const lines = yield* makeInvoiceLines;
|
|
117
|
+
const inserted = yield* lines.insert({
|
|
118
|
+
amount: 52,
|
|
119
|
+
name: "Consulting",
|
|
120
|
+
note: null,
|
|
121
|
+
});
|
|
122
|
+
const updated = yield* lines.update({
|
|
123
|
+
...inserted,
|
|
124
|
+
amount: 55,
|
|
125
|
+
name: "Onsite consulting",
|
|
126
|
+
});
|
|
127
|
+
const found = yield* lines.findById(updated.id);
|
|
128
|
+
yield* lines.delete(found.id);
|
|
129
|
+
return yield* lines.findMany({ where: { id: found.id } });
|
|
130
130
|
});
|
|
131
131
|
```
|
|
132
132
|
|
|
@@ -144,15 +144,15 @@ For lists, choose the fields the caller needs:
|
|
|
144
144
|
|
|
145
145
|
```ts
|
|
146
146
|
const listInvoiceLines = (name: string | undefined) =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
const lines = yield* makeInvoiceLines;
|
|
149
|
+
return yield* lines.findMany({
|
|
150
|
+
where: { amount: 52, name, note: null },
|
|
151
|
+
select: ["name", "amount"],
|
|
152
|
+
orderBy: { field: "name", direction: "desc" },
|
|
153
|
+
limit: 20,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
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 +267,30 @@ Here `archive` is another unversioned `InvoiceLine` repository with its own tabl
|
|
|
267
267
|
|
|
268
268
|
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
269
|
|
|
270
|
-
|
|
271
270
|
### Composite keys and idempotent writes
|
|
272
271
|
|
|
273
272
|
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
273
|
|
|
275
274
|
```ts
|
|
276
275
|
class Reading extends Model.Class<Reading>("Reading")({
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
276
|
+
source: Schema.String,
|
|
277
|
+
sequence: Schema.NumberFromString,
|
|
278
|
+
amount: Schema.NumberFromString,
|
|
279
|
+
label: Schema.String,
|
|
281
280
|
}) {}
|
|
282
281
|
|
|
283
282
|
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 };
|
|
283
|
+
const readings = yield* makeRepository(Reading, {
|
|
284
|
+
tableName: "readings",
|
|
285
|
+
idColumn: ["source", "sequence"],
|
|
286
|
+
conflictColumns: ["source", "sequence"],
|
|
287
|
+
spanPrefix: "Reading",
|
|
288
|
+
});
|
|
289
|
+
const input = { source: "meter", sequence: 1, amount: 52, label: "Imported" };
|
|
290
|
+
const inserted = yield* readings.insertOrIgnore(input);
|
|
291
|
+
const saved = yield* readings.upsert({ ...input, amount: 60 }, { updateColumns: ["amount"] });
|
|
292
|
+
const found = yield* readings.findById({ source: "meter", sequence: 1 });
|
|
293
|
+
return { inserted, saved, found };
|
|
298
294
|
});
|
|
299
295
|
```
|
|
300
296
|
|
|
@@ -302,12 +298,12 @@ The application creates `readings` with `PRIMARY KEY (source, sequence)`; `seque
|
|
|
302
298
|
|
|
303
299
|
`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
300
|
|
|
305
|
-
| Method
|
|
306
|
-
|
|
|
307
|
-
| `insertOrIgnore`
|
|
308
|
-
| `upsert`
|
|
309
|
-
| `insertManyOrIgnore` | Readonly array of model insert inputs
|
|
310
|
-
| `upsertMany`
|
|
301
|
+
| Method | Input | Result |
|
|
302
|
+
| -------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
303
|
+
| `insertOrIgnore` | Model insert input | `Option.some` of the decoded inserted model, or `Option.none` when the declared key conflicts. |
|
|
304
|
+
| `upsert` | Model insert input and `{ updateColumns: [...] }` | `{ row, inserted }`, with the decoded model and a boolean identifying an insert. |
|
|
305
|
+
| `insertManyOrIgnore` | Readonly array of model insert inputs | Decoded inserted models; conflicting rows are omitted. |
|
|
306
|
+
| `upsertMany` | Readonly array of model insert inputs and `{ updateColumns: [...] }` | An array of `{ row, inserted }` results. |
|
|
311
307
|
|
|
312
308
|
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
309
|
|
|
@@ -369,27 +365,27 @@ Declare a numeric version field in the model's select and update variants, leave
|
|
|
369
365
|
|
|
370
366
|
```ts
|
|
371
367
|
class Document extends Model.Class<Document>("Document")({
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
368
|
+
id: Schema.String,
|
|
369
|
+
ownerId: Schema.String,
|
|
370
|
+
name: Schema.String,
|
|
371
|
+
lockVersion: Model.Field({ select: Schema.Number, update: Schema.Number, json: Schema.Number }),
|
|
376
372
|
}) {}
|
|
377
373
|
|
|
378
374
|
const makeDocuments = makeRepository(Document, {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
375
|
+
tableName: "documents",
|
|
376
|
+
idColumn: ["id", "ownerId"],
|
|
377
|
+
conflictColumns: ["id"],
|
|
378
|
+
spanPrefix: "Document",
|
|
379
|
+
versionColumn: "lockVersion",
|
|
384
380
|
});
|
|
385
381
|
|
|
386
382
|
const reviseDocument = Effect.gen(function* () {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
383
|
+
const documents = yield* makeDocuments;
|
|
384
|
+
const key = { id: crypto.randomUUID(), ownerId: "current-owner" };
|
|
385
|
+
yield* documents.insertOrIgnore({ ...key, name: "Draft" });
|
|
386
|
+
const original = yield* documents.findById(key);
|
|
387
|
+
const saved = yield* documents.update({ ...original, name: "Published" });
|
|
388
|
+
yield* documents.delete(key, saved.lockVersion);
|
|
393
389
|
});
|
|
394
390
|
```
|
|
395
391
|
|
|
@@ -408,19 +404,19 @@ import { Data } from "effect";
|
|
|
408
404
|
import { transact } from "@shivaedev/sql/transact.ts";
|
|
409
405
|
|
|
410
406
|
class StorageUnavailable extends Data.TaggedError("StorageUnavailable")<{
|
|
411
|
-
|
|
407
|
+
readonly reason: string;
|
|
412
408
|
}> {}
|
|
413
409
|
|
|
414
410
|
const saveInvoiceLine = (input: typeof InvoiceLine.update.Type) =>
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
411
|
+
Effect.gen(function* () {
|
|
412
|
+
const lines = yield* makeInvoiceLines;
|
|
413
|
+
const saved = yield* lines.update(input);
|
|
414
|
+
return saved;
|
|
415
|
+
}).pipe(
|
|
416
|
+
transact({
|
|
417
|
+
onSqlError: (error) => new StorageUnavailable({ reason: error.message }),
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
424
420
|
```
|
|
425
421
|
|
|
426
422
|
`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 +428,10 @@ import { bucket } from "@shivaedev/changes/buckets.ts";
|
|
|
432
428
|
import { collection, invalidationKeys } from "@shivaedev/changes/keys.ts";
|
|
433
429
|
|
|
434
430
|
class Note extends Model.Class<Note>("Note")({
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
431
|
+
id: Schema.Number,
|
|
432
|
+
listId: bucket(Schema.Number),
|
|
433
|
+
owner: bucket(Schema.String),
|
|
434
|
+
title: Schema.String,
|
|
439
435
|
}) {}
|
|
440
436
|
|
|
441
437
|
const notes = collection("notes", Note.fields.id, { listId: Note.fields.listId, owner: Note.fields.owner });
|
|
@@ -446,7 +442,6 @@ Use the repository's table name as the collection name in query read keys. Every
|
|
|
446
442
|
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
443
|
|
|
448
444
|
The transaction rules follow the database owner:
|
|
449
|
-
|
|
450
445
|
- A typed failure or interruption before commit rolls back and announces nothing. A failed deferred `COMMIT` fails through `onSqlError` and also announces nothing.
|
|
451
446
|
- 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
447
|
- 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 +461,15 @@ Serialize cooperating jobs inside `transact`:
|
|
|
466
461
|
```ts
|
|
467
462
|
import { advisoryXactLock } from "@shivaedev/sql/advisoryXactLock.ts";
|
|
468
463
|
|
|
469
|
-
const serializeInvoiceJob = (input: typeof InvoiceLine.update.Type) =>
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
)
|
|
464
|
+
const serializeInvoiceJob = (input: typeof InvoiceLine.update.Type) =>
|
|
465
|
+
Effect.gen(function* () {
|
|
466
|
+
yield* advisoryXactLock("jobs:invoice-import");
|
|
467
|
+
yield* saveInvoiceLine(input);
|
|
468
|
+
}).pipe(
|
|
469
|
+
transact({
|
|
470
|
+
onSqlError: (error) => new StorageUnavailable({ reason: error.message }),
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
477
473
|
```
|
|
478
474
|
|
|
479
475
|
`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 +483,8 @@ import { Effect } from "effect";
|
|
|
487
483
|
import { SqlClient } from "effect/sql";
|
|
488
484
|
|
|
489
485
|
export default Effect.gen(function* () {
|
|
490
|
-
|
|
491
|
-
|
|
486
|
+
const sql = yield* SqlClient.SqlClient;
|
|
487
|
+
yield* sql`create table orders (name text primary key)`;
|
|
492
488
|
});
|
|
493
489
|
```
|
|
494
490
|
|
|
@@ -501,9 +497,9 @@ import { migrationsFromDirectory } from "@shivaedev/sql/migrationsFromDirectory.
|
|
|
501
497
|
import { migratePostgres } from "@shivaedev/sql/migrations.ts";
|
|
502
498
|
|
|
503
499
|
const migrateOrders = migratePostgres({
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
500
|
+
table: "app_migrations",
|
|
501
|
+
lockTimeout: "5 seconds",
|
|
502
|
+
loader: migrationsFromDirectory(process.cwd()).pipe(Effect.provide(NodeServices.layer)),
|
|
507
503
|
});
|
|
508
504
|
```
|
|
509
505
|
|
|
@@ -517,7 +513,7 @@ Use sequential numbers, not timestamps. Effect's Migrator skips any migration wh
|
|
|
517
513
|
|
|
518
514
|
`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
515
|
|
|
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
|
|
516
|
+
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
517
|
|
|
522
518
|
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 become `MigrationError` (`kind: "Failed"`) with the component name and original cause; their failure leaves the application ledger committed.
|
|
523
519
|
|
|
@@ -527,8 +523,7 @@ For a structured readiness readout, use `pendingPostgresMigrations({ loader, tab
|
|
|
527
523
|
|
|
528
524
|
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
525
|
|
|
530
|
-
|
|
531
|
-
|
|
526
|
+
`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; 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
527
|
|
|
533
528
|
### PostgreSQL delivery between processes
|
|
534
529
|
|
|
@@ -536,9 +531,9 @@ Server's `appDatabase` supplies the optional `WorkerRelease` Context service in
|
|
|
536
531
|
|
|
537
532
|
```ts
|
|
538
533
|
const delivery = postgresChanges({
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
534
|
+
channel: "application_changes",
|
|
535
|
+
client: pg,
|
|
536
|
+
resync: hints.resync,
|
|
542
537
|
});
|
|
543
538
|
```
|
|
544
539
|
|
|
@@ -551,26 +546,29 @@ NOTIFY is a wake-up signal, not a durable log. Transaction payloads contain only
|
|
|
551
546
|
### API
|
|
552
547
|
|
|
553
548
|
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
|
-
|
|
549
|
+
- `@shivaedev/sql/clientOptions.ts`: `clientOptions(options, { snakeCase? })`, `SqlNameTransforms` — Add default native name transforms to driver or app host options; opt out with `snakeCase: false`.
|
|
550
|
+
- `@shivaedev/sql/repository.ts`: `makeRepository(model, options)` — `tableName`, scalar or composite `idColumn`, `spanPrefix`, optional `versionColumn`, `conflictColumns` and `conflictWhere`; requires native `SqlClient`.
|
|
551
|
+
- `@shivaedev/sql/repository.ts`: `OptimisticConcurrencyError` — Typed stale or missing-row failure for versioned writes, with `tableName`, `id`, `expectedVersion`, `reason: "missing" | "stale"`.
|
|
552
|
+
- `@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.
|
|
553
|
+
- `@shivaedev/sql/upsertResult.ts`: `UpsertRow` — The `{ row, inserted }` result of an unconditional upsert.
|
|
554
|
+
- `@shivaedev/sql/conditionalUpsert.ts`: `UpsertFilter`, `UpsertResult` — The `updateWhere` filter shape and the tagged `Inserted`, `Updated` or `Unchanged` result of a conditional upsert.
|
|
555
|
+
- `@shivaedev/sql/mapSqlError.ts`: `mapSqlError({ constraints, fallback })` — Mapper from named constraint violations to application errors, with a fallback for every other `SqlError`.
|
|
556
|
+
- `@shivaedev/sql/transact.ts`: `transact(effect, options)` or `effect.pipe(transact(options))` — Required `onSqlError` mapper; requires `SqlClient` and `Reactivity`.
|
|
557
|
+
- `@shivaedev/sql/transact.ts`: `invalidateOnCommit(keys)` — Records keys for its client, or invalidates immediately outside a transaction.
|
|
558
|
+
- `@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`.
|
|
559
|
+
- `@shivaedev/sql/migrationConvention.ts`: `MIGRATIONS_DIRECTORY`, `MIGRATION_FILE_PATTERN` — Application folder and four-digit sequential snake_case TypeScript filename pattern.
|
|
560
|
+
- `@shivaedev/sql/migrationsFromDirectory.ts`: `migrationsFromDirectory(root)` — Discover migrations under the application root with native `FileSystem` and `Path`.
|
|
561
|
+
- `@shivaedev/sql/migrations.ts`: `migratePostgres(options)` — Native loader, required `lockTimeout`, optional `components`, `release` and ledger `table`, default `effect_sql_migrations`.
|
|
562
|
+
- `@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.
|
|
563
|
+
- `@shivaedev/sql/pendingPostgresMigrations.ts`: `pendingPostgresMigrations({ loader, table?, components? })` — Pending ledger migrations as ID and name, and pending schema components as name and detail.
|
|
564
|
+
- `@shivaedev/sql/postgresRelease.ts`: `postgresRelease(release, table?)`, `readPostgresRelease(table?)` — Release marker schema component and its reader.
|
|
565
|
+
- `@shivaedev/sql/postgresChanges.ts`: `postgresChanges(options)` — Scoped PostgreSQL LISTEN/NOTIFY Layer for commit-bound invalidation.
|
|
566
|
+
- `@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
567
|
|
|
570
568
|
### Install and database setup
|
|
571
569
|
|
|
572
570
|
```sh
|
|
573
|
-
pnpm add @shivaedev/sql effect
|
|
571
|
+
pnpm add @shivaedev/sql effect @effect/sql-pg
|
|
574
572
|
```
|
|
575
573
|
|
|
576
574
|
`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 +579,13 @@ import { Layer, Redacted } from "effect";
|
|
|
581
579
|
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
582
580
|
import { clientOptions } from "@shivaedev/sql/clientOptions.ts";
|
|
583
581
|
|
|
584
|
-
const database = Layer.merge(
|
|
585
|
-
PgClient.layer(clientOptions({ url: Redacted.make("postgresql://localhost/example") })),
|
|
586
|
-
Reactivity.layer,
|
|
587
|
-
);
|
|
582
|
+
const database = Layer.merge(PgClient.layer(clientOptions({ url: Redacted.make("postgresql://localhost/example") })), Reactivity.layer);
|
|
588
583
|
|
|
589
584
|
const save = saveInvoiceLine({
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
585
|
+
id: 1,
|
|
586
|
+
name: "Consulting",
|
|
587
|
+
amount: 52,
|
|
588
|
+
note: null,
|
|
594
589
|
}).pipe(Effect.provide(database));
|
|
595
590
|
```
|
|
596
591
|
|
|
@@ -607,22 +602,20 @@ import { mapSqlError } from "@shivaedev/sql/mapSqlError.ts";
|
|
|
607
602
|
import { transact } from "@shivaedev/sql/transact.ts";
|
|
608
603
|
|
|
609
604
|
const onSqlError = mapSqlError({
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
605
|
+
constraints: {
|
|
606
|
+
"invoice_line_name_key": () => new NameTaken(),
|
|
607
|
+
"invoice_line_invoice_fkey": () => new InvoiceMissing(),
|
|
608
|
+
"invoice_line_amount_check": () => new InvalidAmount(),
|
|
609
|
+
},
|
|
610
|
+
fallback: (error) => new StorageUnavailable({ reason: error.message }),
|
|
616
611
|
});
|
|
617
612
|
|
|
618
|
-
const insertNamedLine = lines.insert({ amount: 52, name: "Consulting", note: null }).pipe(
|
|
619
|
-
transact({ onSqlError }),
|
|
620
|
-
);
|
|
613
|
+
const insertNamedLine = lines.insert({ amount: 52, name: "Consulting", note: null }).pipe(transact({ onSqlError }));
|
|
621
614
|
```
|
|
622
615
|
|
|
623
616
|
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
617
|
|
|
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](
|
|
618
|
+
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
619
|
|
|
627
620
|
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
621
|
|
package/dist/transact.js
CHANGED
|
@@ -25,7 +25,7 @@ const guard = Effect.fn("Transact.guard")(function* () {
|
|
|
25
25
|
const invalidations = makeChannel({
|
|
26
26
|
name: "@shivaedev/sql/transact",
|
|
27
27
|
owner: Effect.map(SqlClient.SqlClient, (sql) => sql.transactionService),
|
|
28
|
-
publish: (keys) => Reactivity.
|
|
28
|
+
publish: (keys) => Reactivity.Reactivity.use((reactivity) => Effect.sync(() => reactivity.invalidateUnsafe(keys))),
|
|
29
29
|
unowned: guard(),
|
|
30
30
|
});
|
|
31
31
|
export const ChangeObserver = invalidations.Observer;
|
package/dist/transact.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transact.js","sourceRoot":"","sources":["../src/transact.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAEvC,OAAO,KAAK,UAAU,MAAM,8BAA8B,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,UAAU,EAAiB,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAI5D,SAAS,MAAM,CAAC,KAAc;IAC7B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,KAAK,KAAK,SAAS;QACvH,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;QACf,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACrB,CAAC;AAED,SAAS,MAAM,CAAC,IAAsB;IACrC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;AAC5B,CAAC;AAED,SAAS,OAAO,CAAC,IAAsB;IACtC,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AACjI,CAAC;AAED,MAAM,KAAK,GAAG,MAAM,CAAC,EAAE,CAAC,gBAAgB,CAAC,CAAC,QAAQ,CAAC;IAClD,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;IACvC,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACnE,IAAI,MAAM,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC5B,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CACvB,IAAI,KAAK,CAAC,iHAAiH,CAAC,CAC5H,CAAC;IACH,CAAC;AACF,CAAC,CAAC,CAAC;AAEH,MAAM,aAAa,GAAG,WAAW,CAAiE;IACjG,IAAI,EAAE,yBAAyB;IAC/B,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,kBAAkB,CAAC;IACvE,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"transact.js","sourceRoot":"","sources":["../src/transact.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Schema-derived repositories over native Effect SQL",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -43,23 +43,24 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@shivaedev/changes": "0.5.
|
|
46
|
+
"@shivaedev/changes": "0.5.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@effect/platform-node": "4.0.
|
|
50
|
-
"@effect/sql-pg": "4.0.
|
|
51
|
-
"@effect/sql-sqlite-node": "4.0.
|
|
52
|
-
"@shivaedev/quality": "0.
|
|
53
|
-
"@shivaedev/service": "0.5.
|
|
54
|
-
"@shivaedev/test": "0.10.
|
|
55
|
-
"@shivaedev/test-
|
|
49
|
+
"@effect/platform-node": "4.0.6",
|
|
50
|
+
"@effect/sql-pg": "4.0.6",
|
|
51
|
+
"@effect/sql-sqlite-node": "4.0.6",
|
|
52
|
+
"@shivaedev/quality": "0.45.0",
|
|
53
|
+
"@shivaedev/service": "0.5.1",
|
|
54
|
+
"@shivaedev/test": "0.10.1",
|
|
55
|
+
"@shivaedev/test-sql": "0.11.0",
|
|
56
|
+
"@shivaedev/test-story": "0.24.0",
|
|
56
57
|
"@types/node": "24.19.2",
|
|
57
|
-
"effect": "4.0.
|
|
58
|
+
"effect": "4.0.6",
|
|
58
59
|
"typescript": "7.0.2",
|
|
59
60
|
"vitest": "5.0.5"
|
|
60
61
|
},
|
|
61
62
|
"peerDependencies": {
|
|
62
|
-
"effect": "4.0.
|
|
63
|
+
"effect": "4.0.6"
|
|
63
64
|
},
|
|
64
65
|
"engines": {
|
|
65
66
|
"node": ">=24"
|
package/src/transact.ts
CHANGED
|
@@ -35,7 +35,7 @@ const guard = Effect.fn("Transact.guard")(function* () {
|
|
|
35
35
|
const invalidations = makeChannel<unknown, SqlClient.SqlClient | Reactivity.Reactivity, SqlError>({
|
|
36
36
|
name: "@shivaedev/sql/transact",
|
|
37
37
|
owner: Effect.map(SqlClient.SqlClient, (sql) => sql.transactionService),
|
|
38
|
-
publish: (keys) => Reactivity.
|
|
38
|
+
publish: (keys) => Reactivity.Reactivity.use((reactivity) => Effect.sync(() => reactivity.invalidateUnsafe(keys))),
|
|
39
39
|
unowned: guard(),
|
|
40
40
|
});
|
|
41
41
|
|