@shivaedev/sql 0.23.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 +29 -0
- package/README.md +185 -170
- package/dist/ChangeDeclarations.d.ts +5 -0
- package/dist/ChangeDeclarations.d.ts.map +1 -0
- package/dist/ChangeDeclarations.js +3 -0
- package/dist/ChangeDeclarations.js.map +1 -0
- package/dist/batchWrites.d.ts +1 -1
- package/dist/batchWrites.d.ts.map +1 -1
- package/dist/batchWrites.js +6 -3
- package/dist/batchWrites.js.map +1 -1
- package/dist/compositeRepository.d.ts +2 -2
- package/dist/compositeRepository.d.ts.map +1 -1
- package/dist/compositeRepository.js +1 -8
- package/dist/compositeRepository.js.map +1 -1
- package/dist/conditionalUpsert.d.ts +11 -4
- package/dist/conditionalUpsert.d.ts.map +1 -1
- package/dist/conditionalUpsert.js +7 -3
- package/dist/conditionalUpsert.js.map +1 -1
- package/dist/conflictWrites.d.ts +14 -1
- package/dist/conflictWrites.d.ts.map +1 -1
- package/dist/conflictWrites.js +5 -3
- package/dist/conflictWrites.js.map +1 -1
- package/dist/insertFromSelect.js +1 -1
- package/dist/insertFromSelect.js.map +1 -1
- package/dist/mapSqlError.d.ts +6 -0
- package/dist/mapSqlError.d.ts.map +1 -0
- package/dist/mapSqlError.js +22 -0
- package/dist/mapSqlError.js.map +1 -0
- package/dist/repository.js +1 -1
- package/dist/repository.js.map +1 -1
- package/dist/repositoryChanges.d.ts +4 -3
- package/dist/repositoryChanges.d.ts.map +1 -1
- package/dist/repositoryChanges.js +46 -13
- package/dist/repositoryChanges.js.map +1 -1
- package/dist/repositoryWrites.d.ts.map +1 -1
- package/dist/repositoryWrites.js +5 -5
- package/dist/repositoryWrites.js.map +1 -1
- package/dist/testing.d.ts +3 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +25 -3
- package/dist/testing.js.map +1 -1
- package/dist/transact.js +1 -1
- package/dist/transact.js.map +1 -1
- package/dist/upsertResult.d.ts +13 -0
- package/dist/upsertResult.d.ts.map +1 -0
- package/dist/upsertResult.js +9 -0
- package/dist/upsertResult.js.map +1 -0
- package/dist/versionedRepository.js +6 -6
- package/dist/versionedRepository.js.map +1 -1
- package/dist/versionedStatements.d.ts +0 -1
- package/dist/versionedStatements.d.ts.map +1 -1
- package/dist/versionedStatements.js +1 -2
- package/dist/versionedStatements.js.map +1 -1
- package/package.json +12 -11
- package/src/ChangeDeclarations.ts +5 -0
- package/src/batchWrites.ts +6 -2
- package/src/compositeRepository.ts +3 -9
- package/src/conditionalUpsert.ts +11 -7
- package/src/conflictWrites.ts +5 -3
- package/src/insertFromSelect.ts +1 -1
- package/src/mapSqlError.ts +31 -0
- package/src/repository.ts +1 -1
- package/src/repositoryChanges.ts +56 -19
- package/src/repositoryWrites.ts +7 -5
- package/src/testing.ts +30 -5
- package/src/transact.ts +1 -1
- package/src/upsertResult.ts +18 -0
- package/src/versionedRepository.ts +6 -6
- package/src/versionedStatements.ts +1 -2
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ const rows = yield* lines.findMany({
|
|
|
23
23
|
|
|
24
24
|
The application owns the database schema and its native Effect `SqlClient` layer. 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
|
-
Repository writes announce their table and returned row keys automatically.
|
|
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
|
|
|
28
28
|
Migrations are ordinary Effects loaded by native Migrator. `migratePostgres` adds coordination for PostgreSQL runners sharing a ledger. It does not derive a table from a model: migration SQL and row codecs must agree.
|
|
29
29
|
|
|
@@ -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
|
|
|
@@ -337,7 +333,9 @@ const result = yield* pullRequests.upsert(incoming, {
|
|
|
337
333
|
|
|
338
334
|
`updateWhere` uses the repository filter shape: equality, `in`, ranges and text patterns combine with AND. A field names the stored column; `{ excluded: "field" }` refers to an incoming column of the same decoded type, in equality or range operands. Literal values pass through their field codecs. The condition becomes `DO UPDATE SET ... WHERE ...`, separate from the index predicate `conflictWhere`, and does not restrict inserts. PostgreSQL evaluates it while holding the conflicting row lock, so an older or equal incoming timestamp cannot overwrite a newer stored revision.
|
|
339
335
|
|
|
340
|
-
|
|
336
|
+
On PostgreSQL, `upsert` returns `{ row, inserted }`: `row` is the decoded model and `inserted` is `true` for an insert and `false` for an update. Read the model from `saved.row` or destructure `{ row }`. `upsertMany` returns the same shape for each returned row. `UpsertRow` is exported from `@shivaedev/sql/upsertResult.ts`.
|
|
337
|
+
|
|
338
|
+
With `updateWhere`, the typed result is `{ _tag: "Inserted", row, inserted: true }`, `{ _tag: "Updated", row, inserted: false }` or `{ _tag: "Unchanged" }`. Unchanged results carry no row and announce no change keys. `UpsertFilter` and `UpsertResult` are exported from `@shivaedev/sql/conditionalUpsert.ts`.
|
|
341
339
|
|
|
342
340
|
`upsertMany(inputs, { updateColumns, updateWhere })` accepts the same condition and returns one tagged result per input, in input order. It executes one statement per input inside one transaction, so a later failure rolls back earlier writes. This uses more database round trips than an unconditional batch. Other batch calls use the chunked statements described below.
|
|
343
341
|
|
|
@@ -345,7 +343,7 @@ Versioned repositories with `conflictColumns` offer `insertOrIgnore`, including
|
|
|
345
343
|
|
|
346
344
|
### Batch writes
|
|
347
345
|
|
|
348
|
-
Every unversioned repository also offers `insertMany(inputs)`, without requiring `conflictColumns`. Each batch method accepts a readonly array of decoded model insert inputs.
|
|
346
|
+
Every unversioned repository also offers `insertMany(inputs)`, without requiring `conflictColumns`. Each batch method accepts a readonly array of decoded model insert inputs. `insertMany` and `insertManyOrIgnore` return decoded models; `upsertMany` returns `{ row, inserted }` results, or the tagged outcomes described above when conditional. Empty inputs return `[]` without writing.
|
|
349
347
|
|
|
350
348
|
```ts
|
|
351
349
|
const saved = yield* readings.upsertMany(
|
|
@@ -367,27 +365,27 @@ Declare a numeric version field in the model's select and update variants, leave
|
|
|
367
365
|
|
|
368
366
|
```ts
|
|
369
367
|
class Document extends Model.Class<Document>("Document")({
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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 }),
|
|
374
372
|
}) {}
|
|
375
373
|
|
|
376
374
|
const makeDocuments = makeRepository(Document, {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
375
|
+
tableName: "documents",
|
|
376
|
+
idColumn: ["id", "ownerId"],
|
|
377
|
+
conflictColumns: ["id"],
|
|
378
|
+
spanPrefix: "Document",
|
|
379
|
+
versionColumn: "lockVersion",
|
|
382
380
|
});
|
|
383
381
|
|
|
384
382
|
const reviseDocument = Effect.gen(function* () {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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);
|
|
391
389
|
});
|
|
392
390
|
```
|
|
393
391
|
|
|
@@ -406,27 +404,44 @@ import { Data } from "effect";
|
|
|
406
404
|
import { transact } from "@shivaedev/sql/transact.ts";
|
|
407
405
|
|
|
408
406
|
class StorageUnavailable extends Data.TaggedError("StorageUnavailable")<{
|
|
409
|
-
|
|
407
|
+
readonly reason: string;
|
|
410
408
|
}> {}
|
|
411
409
|
|
|
412
410
|
const saveInvoiceLine = (input: typeof InvoiceLine.update.Type) =>
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
+
);
|
|
422
420
|
```
|
|
423
421
|
|
|
424
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.
|
|
425
423
|
|
|
426
|
-
|
|
424
|
+
Mark bucket fields on the model with `bucket` from `@shivaedev/changes/buckets.ts`:
|
|
427
425
|
|
|
428
|
-
|
|
426
|
+
```ts
|
|
427
|
+
import { bucket } from "@shivaedev/changes/buckets.ts";
|
|
428
|
+
import { collection, invalidationKeys } from "@shivaedev/changes/keys.ts";
|
|
429
|
+
|
|
430
|
+
class Note extends Model.Class<Note>("Note")({
|
|
431
|
+
id: Schema.Number,
|
|
432
|
+
listId: bucket(Schema.Number),
|
|
433
|
+
owner: bucket(Schema.String),
|
|
434
|
+
title: Schema.String,
|
|
435
|
+
}) {}
|
|
436
|
+
|
|
437
|
+
const notes = collection("notes", Note.fields.id, { listId: Note.fields.listId, owner: Note.fields.owner });
|
|
438
|
+
```
|
|
429
439
|
|
|
440
|
+
Use the repository's table name as the collection name in query read keys. Every inserted, updated or deleted row announces its record key and each declared bucket from the old and new stored values. An insert belongs only to its new buckets; a move announces both sides; a delete announces the old buckets. Bucket codecs decode stored values before producing keys, so a `bucket(Schema.NumberFromString)` field matches a numeric query bucket even when stored as text. Nullable and optional bucket values contribute no key when absent. Bucket codecs must need no services and decode to strings, numbers or absent values.
|
|
441
|
+
|
|
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.
|
|
443
|
+
|
|
444
|
+
The transaction rules follow the database owner:
|
|
430
445
|
- A typed failure or interruption before commit rolls back and announces nothing. A failed deferred `COMMIT` fails through `onSqlError` and also announces nothing.
|
|
431
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.
|
|
432
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.
|
|
@@ -446,14 +461,15 @@ Serialize cooperating jobs inside `transact`:
|
|
|
446
461
|
```ts
|
|
447
462
|
import { advisoryXactLock } from "@shivaedev/sql/advisoryXactLock.ts";
|
|
448
463
|
|
|
449
|
-
const serializeInvoiceJob = (input: typeof InvoiceLine.update.Type) =>
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
)
|
|
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
|
+
);
|
|
457
473
|
```
|
|
458
474
|
|
|
459
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.
|
|
@@ -467,8 +483,8 @@ import { Effect } from "effect";
|
|
|
467
483
|
import { SqlClient } from "effect/sql";
|
|
468
484
|
|
|
469
485
|
export default Effect.gen(function* () {
|
|
470
|
-
|
|
471
|
-
|
|
486
|
+
const sql = yield* SqlClient.SqlClient;
|
|
487
|
+
yield* sql`create table orders (name text primary key)`;
|
|
472
488
|
});
|
|
473
489
|
```
|
|
474
490
|
|
|
@@ -481,9 +497,9 @@ import { migrationsFromDirectory } from "@shivaedev/sql/migrationsFromDirectory.
|
|
|
481
497
|
import { migratePostgres } from "@shivaedev/sql/migrations.ts";
|
|
482
498
|
|
|
483
499
|
const migrateOrders = migratePostgres({
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
500
|
+
table: "app_migrations",
|
|
501
|
+
lockTimeout: "5 seconds",
|
|
502
|
+
loader: migrationsFromDirectory(process.cwd()).pipe(Effect.provide(NodeServices.layer)),
|
|
487
503
|
});
|
|
488
504
|
```
|
|
489
505
|
|
|
@@ -497,7 +513,7 @@ Use sequential numbers, not timestamps. Effect's Migrator skips any migration wh
|
|
|
497
513
|
|
|
498
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.
|
|
499
515
|
|
|
500
|
-
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.
|
|
501
517
|
|
|
502
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.
|
|
503
519
|
|
|
@@ -507,8 +523,7 @@ For a structured readiness readout, use `pendingPostgresMigrations({ loader, tab
|
|
|
507
523
|
|
|
508
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.
|
|
509
525
|
|
|
510
|
-
|
|
511
|
-
|
|
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.
|
|
512
527
|
|
|
513
528
|
### PostgreSQL delivery between processes
|
|
514
529
|
|
|
@@ -516,9 +531,9 @@ Server's `appDatabase` supplies the optional `WorkerRelease` Context service in
|
|
|
516
531
|
|
|
517
532
|
```ts
|
|
518
533
|
const delivery = postgresChanges({
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
534
|
+
channel: "application_changes",
|
|
535
|
+
client: pg,
|
|
536
|
+
resync: hints.resync,
|
|
522
537
|
});
|
|
523
538
|
```
|
|
524
539
|
|
|
@@ -531,26 +546,29 @@ NOTIFY is a wake-up signal, not a durable log. Transaction payloads contain only
|
|
|
531
546
|
### API
|
|
532
547
|
|
|
533
548
|
Import the module that defines the name; there is no root entry.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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.
|
|
549
567
|
|
|
550
568
|
### Install and database setup
|
|
551
569
|
|
|
552
570
|
```sh
|
|
553
|
-
pnpm add @shivaedev/sql effect
|
|
571
|
+
pnpm add @shivaedev/sql effect @effect/sql-pg
|
|
554
572
|
```
|
|
555
573
|
|
|
556
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`:
|
|
@@ -561,16 +579,13 @@ import { Layer, Redacted } from "effect";
|
|
|
561
579
|
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
562
580
|
import { clientOptions } from "@shivaedev/sql/clientOptions.ts";
|
|
563
581
|
|
|
564
|
-
const database = Layer.merge(
|
|
565
|
-
PgClient.layer(clientOptions({ url: Redacted.make("postgresql://localhost/example") })),
|
|
566
|
-
Reactivity.layer,
|
|
567
|
-
);
|
|
582
|
+
const database = Layer.merge(PgClient.layer(clientOptions({ url: Redacted.make("postgresql://localhost/example") })), Reactivity.layer);
|
|
568
583
|
|
|
569
584
|
const save = saveInvoiceLine({
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
585
|
+
id: 1,
|
|
586
|
+
name: "Consulting",
|
|
587
|
+
amount: 52,
|
|
588
|
+
note: null,
|
|
574
589
|
}).pipe(Effect.provide(database));
|
|
575
590
|
```
|
|
576
591
|
|
|
@@ -580,29 +595,27 @@ The application's startup runs migrations and keeps its database scope alive for
|
|
|
580
595
|
|
|
581
596
|
Query decoding and invalid limits can fail with `SchemaError`; SQL statement failures remain `SqlError`. `findById` adds `NoSuchElementError` for a missing row. Unversioned `update` defects if it returns no row unless called with `{ onMissing: "fail" }`, which keeps `NoSuchElementError` in the error channel. Versioned updates and deletes instead fail with typed `OptimisticConcurrencyError` when their ID and expected version match no row. A failed `COMMIT` is a typed `SqlError` like any other statement failure.
|
|
582
597
|
|
|
583
|
-
Map
|
|
598
|
+
Map named constraints at the operation that understands them. `mapSqlError` returns a mapper for `transact`'s `onSqlError` or `Effect.mapError` on an Effect whose error is `SqlError`:
|
|
584
599
|
|
|
585
600
|
```ts
|
|
586
|
-
import
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const insertNamedLine = Effect.gen(function* () {
|
|
597
|
-
const sql = yield* SqlClient.SqlClient;
|
|
598
|
-
const lines = yield* makeInvoiceLines;
|
|
599
|
-
return yield* sql
|
|
600
|
-
.withTransaction(lines.insert({ amount: 52, name: "Consulting", note: null }))
|
|
601
|
-
.pipe(Effect.catchTag("SqlError", knownNameConflict));
|
|
601
|
+
import { mapSqlError } from "@shivaedev/sql/mapSqlError.ts";
|
|
602
|
+
import { transact } from "@shivaedev/sql/transact.ts";
|
|
603
|
+
|
|
604
|
+
const onSqlError = mapSqlError({
|
|
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 }),
|
|
602
611
|
});
|
|
612
|
+
|
|
613
|
+
const insertNamedLine = lines.insert({ amount: 52, name: "Consulting", note: null }).pipe(transact({ onSqlError }));
|
|
603
614
|
```
|
|
604
615
|
|
|
605
|
-
The
|
|
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.
|
|
617
|
+
|
|
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).
|
|
606
619
|
|
|
607
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.
|
|
608
621
|
|
|
@@ -612,18 +625,20 @@ JSON and JSONB columns are not supported. A repository write of a plain object t
|
|
|
612
625
|
|
|
613
626
|
The package's PostgreSQL tests use a disposable database through `PLATFORM_SQL_TEST_DATABASE_URL`. Without the URL they skip locally; under `CI`, collection fails at the shared gate.
|
|
614
627
|
|
|
615
|
-
The sql story kit exposes `withChangeCoverage` and `expectPublished`, also exported from `@shivaedev/sql/testing.ts`. `withChangeCoverage(body, { covers })` checks PostgreSQL transaction write counters before commit
|
|
628
|
+
The sql story kit exposes `withChangeCoverage` and `expectPublished`, also exported from `@shivaedev/sql/testing.ts`. `withChangeCoverage(body, { covers })` checks PostgreSQL transaction write counters before commit and requires every declared record and bucket key to be recorded. Repository writes supply these declarations automatically. Raw writes must declare their affected keys through `covers` and record them through the same after-commit path; a table key alone covers nothing. Missing announcements fail with `UnannouncedWrites` and roll back the transaction.
|
|
616
629
|
|
|
617
630
|
```ts
|
|
618
|
-
const
|
|
631
|
+
const changed = [notes.item(id), notes.bucket.listId(oldList), notes.bucket.listId(newList), notes.bucket.owner(owner)];
|
|
619
632
|
|
|
620
633
|
yield* withChangeCoverage(
|
|
621
634
|
Effect.gen(function* () {
|
|
622
|
-
yield* sql`update
|
|
623
|
-
yield* invalidateOnCommit(
|
|
635
|
+
yield* sql`update notes set "listId" = ${newList} where id = ${id}`;
|
|
636
|
+
yield* invalidateOnCommit(invalidationKeys(changed));
|
|
624
637
|
}),
|
|
625
|
-
|
|
626
|
-
)
|
|
638
|
+
{ covers: { notes: changed } },
|
|
639
|
+
);
|
|
627
640
|
```
|
|
628
641
|
|
|
642
|
+
The coverage check verifies the declarations, not the contents of arbitrary SQL. Raw writers must declare every affected record and bucket, including unchanged buckets. Transaction statistics retain writes from rolled-back savepoints, so those writes still need declarations during a coverage check.
|
|
643
|
+
|
|
629
644
|
`body.pipe(expectPublished(keys))` compares the distinct published keys, regardless of order, and preserves the body's result. Put it outside the committing transaction. It observes the existing change channel and leaves delivery intact; recorded keys from rolled-back writes do not count. A mismatch fails with `PublishedKeysMismatch`, whose `expected`, `actual` and message show both key sets. The assertion happens after the body, so a mismatch does not roll back a committed write. See [automatic change delivery](https://github.com/ShivaeDev/platform/blob/main/docs/framework/changes.md) for host defaults and coverage limits.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ChangeDeclarations.d.ts","sourceRoot":"","sources":["../src/ChangeDeclarations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEzC,eAAO,MAAM,kBAAkB;aACrB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;EACU,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ChangeDeclarations.js","sourceRoot":"","sources":["../src/ChangeDeclarations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEzC,MAAM,CAAC,MAAM,kBAAkB,GAAG,OAAO,CAAC,SAAS,CAEhD,mCAAmC,EAAE,EAAE,YAAY,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC"}
|
package/dist/batchWrites.d.ts
CHANGED
|
@@ -12,5 +12,5 @@ export declare function makeInsertMany<TServices = never>(model: Model.Any, sql:
|
|
|
12
12
|
readonly tableName: string;
|
|
13
13
|
readonly spanPrefix: string;
|
|
14
14
|
readonly changes: RepositoryChanges;
|
|
15
|
-
}, operation: string, prepare?: (row: Readonly<Record<string, unknown>>) => Effect.Effect<Fragment, Schema.SchemaError, TServices
|
|
15
|
+
}, operation: string, prepare?: (row: Readonly<Record<string, unknown>>) => Effect.Effect<Fragment, Schema.SchemaError, TServices>, upsert?: boolean): (inputs: readonly unknown[]) => Effect.Effect<unknown[], Schema.SchemaError | SqlError, unknown>;
|
|
16
16
|
//# sourceMappingURL=batchWrites.d.ts.map
|