@shivaedev/sql 0.0.0 → 0.21.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.
Files changed (189) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/LICENSE +21 -0
  3. package/README.md +613 -1
  4. package/dist/SchemaComponent.d.ts +9 -0
  5. package/dist/SchemaComponent.d.ts.map +1 -0
  6. package/dist/SchemaComponent.js +6 -0
  7. package/dist/SchemaComponent.js.map +1 -0
  8. package/dist/WorkerRelease.d.ts +14 -0
  9. package/dist/WorkerRelease.d.ts.map +1 -0
  10. package/dist/WorkerRelease.js +30 -0
  11. package/dist/WorkerRelease.js.map +1 -0
  12. package/dist/advisoryXactLock.d.ts +10 -0
  13. package/dist/advisoryXactLock.d.ts.map +1 -0
  14. package/dist/advisoryXactLock.js +18 -0
  15. package/dist/advisoryXactLock.js.map +1 -0
  16. package/dist/aggregateColumn.d.ts +8 -0
  17. package/dist/aggregateColumn.d.ts.map +1 -0
  18. package/dist/aggregateColumn.js +44 -0
  19. package/dist/aggregateColumn.js.map +1 -0
  20. package/dist/aggregateQuery.d.ts +51 -0
  21. package/dist/aggregateQuery.d.ts.map +1 -0
  22. package/dist/aggregateQuery.js +2 -0
  23. package/dist/aggregateQuery.js.map +1 -0
  24. package/dist/assertPostgresMigrations.d.ts +7 -0
  25. package/dist/assertPostgresMigrations.d.ts.map +1 -0
  26. package/dist/assertPostgresMigrations.js +20 -0
  27. package/dist/assertPostgresMigrations.js.map +1 -0
  28. package/dist/batchWrites.d.ts +16 -0
  29. package/dist/batchWrites.d.ts.map +1 -0
  30. package/dist/batchWrites.js +49 -0
  31. package/dist/batchWrites.js.map +1 -0
  32. package/dist/clientOptions.d.ts +8 -0
  33. package/dist/clientOptions.d.ts.map +1 -0
  34. package/dist/clientOptions.js +8 -0
  35. package/dist/clientOptions.js.map +1 -0
  36. package/dist/compositeRepository.d.ts +41 -0
  37. package/dist/compositeRepository.d.ts.map +1 -0
  38. package/dist/compositeRepository.js +38 -0
  39. package/dist/compositeRepository.js.map +1 -0
  40. package/dist/conditionalUpsert.d.ts +59 -0
  41. package/dist/conditionalUpsert.d.ts.map +1 -0
  42. package/dist/conditionalUpsert.js +28 -0
  43. package/dist/conditionalUpsert.js.map +1 -0
  44. package/dist/conflictWrites.d.ts +45 -0
  45. package/dist/conflictWrites.d.ts.map +1 -0
  46. package/dist/conflictWrites.js +40 -0
  47. package/dist/conflictWrites.js.map +1 -0
  48. package/dist/coordinateWorkerRelease.d.ts +5 -0
  49. package/dist/coordinateWorkerRelease.d.ts.map +1 -0
  50. package/dist/coordinateWorkerRelease.js +30 -0
  51. package/dist/coordinateWorkerRelease.js.map +1 -0
  52. package/dist/insertFromSelect.d.ts +24 -0
  53. package/dist/insertFromSelect.d.ts.map +1 -0
  54. package/dist/insertFromSelect.js +15 -0
  55. package/dist/insertFromSelect.js.map +1 -0
  56. package/dist/latestPerGroup.d.ts +11 -0
  57. package/dist/latestPerGroup.d.ts.map +1 -0
  58. package/dist/latestPerGroup.js +51 -0
  59. package/dist/latestPerGroup.js.map +1 -0
  60. package/dist/listenForChanges.d.ts +5 -0
  61. package/dist/listenForChanges.d.ts.map +1 -0
  62. package/dist/listenForChanges.js +75 -0
  63. package/dist/listenForChanges.js.map +1 -0
  64. package/dist/makeAggregate.d.ts +8 -0
  65. package/dist/makeAggregate.d.ts.map +1 -0
  66. package/dist/makeAggregate.js +38 -0
  67. package/dist/makeAggregate.js.map +1 -0
  68. package/dist/makeConflictClause.d.ts +10 -0
  69. package/dist/makeConflictClause.d.ts.map +1 -0
  70. package/dist/makeConflictClause.js +41 -0
  71. package/dist/makeConflictClause.js.map +1 -0
  72. package/dist/makeQueryClauses.d.ts +14 -0
  73. package/dist/makeQueryClauses.d.ts.map +1 -0
  74. package/dist/makeQueryClauses.js +20 -0
  75. package/dist/makeQueryClauses.js.map +1 -0
  76. package/dist/migrationConvention.d.ts +3 -0
  77. package/dist/migrationConvention.d.ts.map +1 -0
  78. package/dist/migrationConvention.js +3 -0
  79. package/dist/migrationConvention.js.map +1 -0
  80. package/dist/migrations.d.ts +12 -0
  81. package/dist/migrations.d.ts.map +1 -0
  82. package/dist/migrations.js +41 -0
  83. package/dist/migrations.js.map +1 -0
  84. package/dist/migrationsFromDirectory.d.ts +4 -0
  85. package/dist/migrationsFromDirectory.d.ts.map +1 -0
  86. package/dist/migrationsFromDirectory.js +7 -0
  87. package/dist/migrationsFromDirectory.js.map +1 -0
  88. package/dist/notifyPayload.d.ts +7 -0
  89. package/dist/notifyPayload.d.ts.map +1 -0
  90. package/dist/notifyPayload.js +31 -0
  91. package/dist/notifyPayload.js.map +1 -0
  92. package/dist/pendingPostgresMigrations.d.ts +24 -0
  93. package/dist/pendingPostgresMigrations.d.ts.map +1 -0
  94. package/dist/pendingPostgresMigrations.js +30 -0
  95. package/dist/pendingPostgresMigrations.js.map +1 -0
  96. package/dist/pinnedGroups.d.ts +22 -0
  97. package/dist/pinnedGroups.d.ts.map +1 -0
  98. package/dist/pinnedGroups.js +35 -0
  99. package/dist/pinnedGroups.js.map +1 -0
  100. package/dist/postgresChanges.d.ts +20 -0
  101. package/dist/postgresChanges.d.ts.map +1 -0
  102. package/dist/postgresChanges.js +26 -0
  103. package/dist/postgresChanges.js.map +1 -0
  104. package/dist/postgresRelease.d.ts +9 -0
  105. package/dist/postgresRelease.d.ts.map +1 -0
  106. package/dist/postgresRelease.js +39 -0
  107. package/dist/postgresRelease.js.map +1 -0
  108. package/dist/releaseWorkers.d.ts +8 -0
  109. package/dist/releaseWorkers.d.ts.map +1 -0
  110. package/dist/releaseWorkers.js +26 -0
  111. package/dist/releaseWorkers.js.map +1 -0
  112. package/dist/repository.d.ts +66 -0
  113. package/dist/repository.d.ts.map +1 -0
  114. package/dist/repository.js +43 -0
  115. package/dist/repository.js.map +1 -0
  116. package/dist/repositoryChanges.d.ts +9 -0
  117. package/dist/repositoryChanges.d.ts.map +1 -0
  118. package/dist/repositoryChanges.js +32 -0
  119. package/dist/repositoryChanges.js.map +1 -0
  120. package/dist/repositoryFilter.d.ts +43 -0
  121. package/dist/repositoryFilter.d.ts.map +1 -0
  122. package/dist/repositoryFilter.js +69 -0
  123. package/dist/repositoryFilter.js.map +1 -0
  124. package/dist/repositoryQueries.d.ts +26 -0
  125. package/dist/repositoryQueries.d.ts.map +1 -0
  126. package/dist/repositoryQueries.js +49 -0
  127. package/dist/repositoryQueries.js.map +1 -0
  128. package/dist/repositoryWrites.d.ts +18 -0
  129. package/dist/repositoryWrites.d.ts.map +1 -0
  130. package/dist/repositoryWrites.js +49 -0
  131. package/dist/repositoryWrites.js.map +1 -0
  132. package/dist/testing.d.ts +12 -0
  133. package/dist/testing.d.ts.map +1 -0
  134. package/dist/testing.js +29 -0
  135. package/dist/testing.js.map +1 -0
  136. package/dist/transact.d.ts +18 -0
  137. package/dist/transact.d.ts.map +1 -0
  138. package/dist/transact.js +43 -0
  139. package/dist/transact.js.map +1 -0
  140. package/dist/tupleIn.d.ts +19 -0
  141. package/dist/tupleIn.d.ts.map +1 -0
  142. package/dist/tupleIn.js +20 -0
  143. package/dist/tupleIn.js.map +1 -0
  144. package/dist/versionedRepository.d.ts +37 -0
  145. package/dist/versionedRepository.d.ts.map +1 -0
  146. package/dist/versionedRepository.js +63 -0
  147. package/dist/versionedRepository.js.map +1 -0
  148. package/dist/versionedStatements.d.ts +25 -0
  149. package/dist/versionedStatements.d.ts.map +1 -0
  150. package/dist/versionedStatements.js +75 -0
  151. package/dist/versionedStatements.js.map +1 -0
  152. package/package.json +71 -4
  153. package/src/SchemaComponent.ts +12 -0
  154. package/src/WorkerRelease.ts +38 -0
  155. package/src/advisoryXactLock.ts +17 -0
  156. package/src/aggregateColumn.ts +49 -0
  157. package/src/aggregateQuery.ts +57 -0
  158. package/src/assertPostgresMigrations.ts +24 -0
  159. package/src/batchWrites.ts +69 -0
  160. package/src/clientOptions.ts +16 -0
  161. package/src/compositeRepository.ts +101 -0
  162. package/src/conditionalUpsert.ts +97 -0
  163. package/src/conflictWrites.ts +93 -0
  164. package/src/coordinateWorkerRelease.ts +33 -0
  165. package/src/insertFromSelect.ts +47 -0
  166. package/src/latestPerGroup.ts +58 -0
  167. package/src/listenForChanges.ts +97 -0
  168. package/src/makeAggregate.ts +64 -0
  169. package/src/makeConflictClause.ts +53 -0
  170. package/src/makeQueryClauses.ts +36 -0
  171. package/src/migrationConvention.ts +2 -0
  172. package/src/migrations.ts +58 -0
  173. package/src/migrationsFromDirectory.ts +7 -0
  174. package/src/notifyPayload.ts +34 -0
  175. package/src/pendingPostgresMigrations.ts +42 -0
  176. package/src/pinnedGroups.ts +46 -0
  177. package/src/postgresChanges.ts +48 -0
  178. package/src/postgresRelease.ts +41 -0
  179. package/src/releaseWorkers.ts +32 -0
  180. package/src/repository.ts +147 -0
  181. package/src/repositoryChanges.ts +49 -0
  182. package/src/repositoryFilter.ts +108 -0
  183. package/src/repositoryQueries.ts +107 -0
  184. package/src/repositoryWrites.ts +70 -0
  185. package/src/testing.ts +38 -0
  186. package/src/transact.ts +70 -0
  187. package/src/tupleIn.ts +44 -0
  188. package/src/versionedRepository.ts +108 -0
  189. package/src/versionedStatements.ts +90 -0
package/README.md CHANGED
@@ -1,3 +1,615 @@
1
1
  # @shivaedev/sql
2
2
 
3
- Placeholder. Real releases start above 0.0.0.
3
+ Describe a database row once with an Effect model and use it for writes, filters and decoded reads. Small repositories remove routine SQL work, while native transactions and migrations keep the database's ownership visible.
4
+
5
+ ## Why you want this
6
+
7
+ A row type, an insert input, a filter type and a selected-result decoder can all disagree about the same field. This package takes them from the model, so a feature writes its query instead of maintaining another database interface:
8
+
9
+ ```ts
10
+ const rows = yield* lines.findMany({
11
+ where: { amount: 52, note: null },
12
+ select: ["name", "amount"],
13
+ orderBy: { field: "name", direction: "asc" },
14
+ limit: 20,
15
+ });
16
+ ```
17
+
18
+ `lines` is an invoice-line repository. Its model makes `amount` a number in application code even when the column stores text. This query filters with the number `52` and returns decoded records whose type contains only `name` and `amount`. The same model supplies writable inputs and database-generated IDs.
19
+
20
+ ## Using it
21
+
22
+ ### How to think about it
23
+
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
+
26
+ Repository writes announce their table and returned row keys automatically. A single write returning more than 1,000 rows across all its chunks announces only the table and `<table>:*`; writes returning up to 1,000 rows keep their item keys. Effect-contract item reads also react to the collection-wide key. 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
+
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
+
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
+
32
+ ### SQL names: configure the client once
33
+
34
+ ```ts
35
+ import * as PgClient from "@effect/sql-pg/PgClient";
36
+ import { Redacted } from "effect";
37
+ import { clientOptions } from "@shivaedev/sql/clientOptions.ts";
38
+
39
+ const database = PgClient.layer(clientOptions({
40
+ url: Redacted.make("postgresql://localhost/example"),
41
+ }));
42
+ ```
43
+
44
+ `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
+
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 `platform`'s `appHost` or `appDatabase`, use `clientOptions` on their `config` object; the host forwards those native transforms to its client.
47
+
48
+ ### 1. Once per model: fields and writable variants
49
+
50
+ ```ts
51
+ import { Effect, Schema } from "effect";
52
+ import { Model } from "effect/schema";
53
+ import { makeRepository } from "@shivaedev/sql/repository.ts";
54
+
55
+ class InvoiceLine extends Model.Class<InvoiceLine>("InvoiceLine")({
56
+ amount: Schema.NumberFromString,
57
+ id: Model.Field({
58
+ select: Schema.Number,
59
+ update: Schema.Number,
60
+ json: Schema.Number,
61
+ }),
62
+ name: Schema.String,
63
+ note: Schema.NullOr(Schema.String),
64
+ }) {}
65
+
66
+ const makeInvoiceLines = makeRepository(InvoiceLine, {
67
+ tableName: "invoice_line",
68
+ idColumn: "id",
69
+ spanPrefix: "InvoiceLine",
70
+ });
71
+ ```
72
+
73
+ The database supplies `id` on insert. Its `Model.Field` includes the ID in selected rows, updates and JSON, and leaves it out of insertion. Keep the repository's ID field in the update variant. An ordinary field such as `name` participates in the writable variants; native update takes that update shape, so pass the complete input the model requires.
74
+
75
+ `amount` uses a text column in this example. `Schema.NumberFromString` encodes a number for storage and decodes the driver's string on reads. The SQL schema still owns column types, constraints and defaults.
76
+
77
+ A generated creation time can have a different database and JSON representation:
78
+
79
+ ```ts
80
+ const createdAt = Model.Field({
81
+ select: Schema.Date,
82
+ json: Schema.DateFromString,
83
+ });
84
+ ```
85
+
86
+ This field is absent from insert and update. A PostgreSQL `timestamptz` default supplies a `Date` on the database boundary, while the JSON codec encodes a string. Choose each field's schema for the values its driver or transport actually returns.
87
+
88
+ For a PostgreSQL `int4[]` column, give the native driver an explicit element OID so an empty array has a type too:
89
+
90
+ ```ts
91
+ import * as PgTypes from "@effect/sql-pg/PgTypes";
92
+ import { Result, SchemaGetter, SchemaParser } from "effect";
93
+
94
+ const Integers = Schema.Array(Schema.Int);
95
+ const StoredIntegers = Schema.Unknown.pipe(
96
+ Schema.decodeTo(Integers, {
97
+ decode: SchemaGetter.transformEffect((value) =>
98
+ SchemaParser.decodeUnknownEffect(Integers)(PgTypes.isParameter(value) ? value.value : value),
99
+ ),
100
+ encode: SchemaGetter.transform((values) => Result.getOrThrow(PgTypes.array(values, PgTypes.OID.int4))),
101
+ }),
102
+ );
103
+
104
+ class Sample extends Model.Class<Sample>("Sample")({
105
+ id: Schema.Int,
106
+ values: Model.Field({ insert: StoredIntegers, select: StoredIntegers, update: StoredIntegers, json: Integers }),
107
+ }) {}
108
+ ```
109
+
110
+ The decoded value is an integer array in every variant. The database variants encode a native typed `int4[]` parameter; JSON retains the ordinary array. The same storage codec on `select` lets equality filters encode empty arrays too. `PgTypes.array` resolves the built-in `int4` array OID; `getOrThrow` unwraps that known constructor result. PostgreSQL still enforces its integer range on writes.
111
+
112
+ ### 2. Per feature: write and query through the model
113
+
114
+ ```ts
115
+ const reviseInvoiceLine = Effect.gen(function* () {
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
+ });
131
+ ```
132
+
133
+ `insert` returns an `InvoiceLine` with the generated ID and decoded amount. The update supplies the model's required fields, the by-ID read reaches the saved row, and the final query returns an empty list after deletion. Repository CRUD uses native model variants and SQL schemas; `findMany` adds model-derived queries.
134
+
135
+ When an unversioned row may be absent, opt into a typed missing-row failure:
136
+
137
+ ```ts
138
+ yield* lines.update(input, { onMissing: "fail" });
139
+ ```
140
+
141
+ This returns the decoded model or fails with `NoSuchElementError` in the error channel, just like `findById`. It supports single-column and composite keys and executes one `UPDATE … RETURNING` statement without checking existence first. A successful update announces changes after commit; a missing row announces nothing. Without the option, a missing row remains a defect. Versioned repositories use their existing `OptimisticConcurrencyError` contract instead.
142
+
143
+ For lists, choose the fields the caller needs:
144
+
145
+ ```ts
146
+ const listInvoiceLines = (name: string | undefined) =>
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
+ ```
157
+
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 }>`.
159
+
160
+ Use `{ in: values }` to match any value in a list, alongside equality entries:
161
+
162
+ ```ts
163
+ yield* lines.findMany({ where: { id: { in: [1, 3, 5] }, amount: 52 } });
164
+ ```
165
+
166
+ Each list value uses the field's codec and decoded type. An empty list matches no rows. A plain array remains an equality value for an array-typed field; only the explicit `{ in: [...] }` entry requests SQL `IN`.
167
+
168
+ Use `count` with the same `where` shape as `findMany` to return the matching row count decoded as a number:
169
+
170
+ ```ts
171
+ yield* lines.count({ where: { amount: { in: [20, 52] }, note: null } });
172
+ yield* lines.count();
173
+ ```
174
+
175
+ Omitting `where` counts every row. An empty table returns `0`.
176
+
177
+ Use `tupleIn` to match several fields together against a list of tuples:
178
+
179
+ ```ts
180
+ import { tupleIn } from "@shivaedev/sql/tupleIn.ts";
181
+
182
+ yield* lines.findMany({
183
+ where: {
184
+ ...tupleIn(InvoiceLine, ["name", "amount"], [["Consulting", 52], ["Travel", 20]]),
185
+ note: null,
186
+ },
187
+ select: ["name", "amount"],
188
+ });
189
+ ```
190
+
191
+ Each tuple matches all its fields together, so this filter does not match `("Consulting", 20)`. The field list has at least two model fields, and each tuple's length and decoded types follow that list. Every value passes through its field codec. Spread the filter beside other entries to combine them with `AND`, or pass it directly as `where`. The same filter works in `select` and unversioned `delete`. An empty tuple list matches nothing, including deleting no rows. Large lists keep every value in a bound parameter.
192
+
193
+ Use `gt`, `gte`, `lt` and `lte` for exclusive or inclusive bounds, and `like` for a text pattern:
194
+
195
+ ```ts
196
+ yield* lines.findMany({
197
+ where: { id: { gte: 2, lt: 10 }, name: { like: "Consult%" } },
198
+ select: ["id", "name"],
199
+ orderBy: { field: "id", direction: "asc" },
200
+ });
201
+ ```
202
+
203
+ Bounds use decoded strings, numbers, bigints, `BigDecimal`, `Date` or `DateTime.Utc` values and run through the field codec. For a `timestamptz` field modeled with `Schema.Date`, use `{ gte: new Date("2026-10-25T00:00:00Z"), lt: new Date("2026-10-26T00:00:00Z") }` to read a UTC day. Multiple operators on a field are joined with `AND`. `like` takes text, with PostgreSQL's case-sensitive `%` and `_` wildcards; patterns are bound parameters. Invalid operand types fail with `SchemaError`. Both `findMany` and `select` accept these filters; repositories query one table.
204
+
205
+ `select` is a nonempty tuple of model field names. Leave it out to select all model fields. `orderBy` takes one `{ field, direction }` object or a nonempty list of them, applied in list order, with `asc` or `desc` for each direction; `limit` takes a nonnegative integer, and zero returns an empty list. Unknown selected or sorted field names passed at runtime fail with `SchemaError`, as do unknown filter keys with a defined value. Filter entries with `undefined` values are omitted before field validation. Use `aggregate` for grouped summaries, and [native SQL with composed schemas](https://github.com/ShivaeDev/platform/blob/main/docs/framework/cross-table-reads.md) for joins, percentiles, `having` or other queries outside these shapes.
206
+
207
+ If a field codec requires a service, provide it for the query. Filter encoding and selected-row decoding retain their required services in the Effect environment.
208
+
209
+ ### Newest rows per group, distinct-on and grouped aggregates
210
+
211
+ Use `latestPerGroup` when each parent needs its own row limit, such as the newest 21 runs per repository, workflow and merge-queue flag on a dashboard:
212
+
213
+ ```ts
214
+ const recentRuns = yield* runs.latestPerGroup({
215
+ partitionBy: ["repositoryId", "workflow", "mergeQueue"],
216
+ orderBy: [
217
+ { field: "createdAt", direction: "desc" },
218
+ { field: "id", direction: "desc" },
219
+ ],
220
+ limit: 21,
221
+ where: { repositoryId: { in: repositoryIds }, status: "completed" },
222
+ });
223
+ ```
224
+
225
+ Here `runs` is a repository whose model declares those fields. `partitionBy` and `orderBy` are nonempty lists of model fields, and ordering reuses the same `{ field, direction }` entries as `findMany`. End the ordering with a unique tie-break column (or columns for a composite key) to make membership and order stable when timestamps tie. The repository does not infer a tie-break. `limit` is a nonnegative integer per partition; zero returns no rows, and smaller groups return all their matching rows.
226
+
227
+ On PostgreSQL, pin every `partitionBy` field in `where` with equality, `null`, an `in` list, or a `tupleIn` over exactly the partition fields to read each group through a `cross join lateral` subquery with its own ordering and limit. Index the partition columns followed by the order columns, with the declared order directions, so each group’s index walk stops after `limit` matching rows. For the example above, pin `workflow` and `mergeQueue` as well as `repositoryId` to use this path. Null pins match null columns, and an empty pinned `in` list returns no rows without querying the database.
228
+
229
+ Otherwise, the typed `where` filter runs before `row_number() over (partition by ... order by ...)` ranks matching rows, and the outer query keeps ranks up to `limit`. Both paths encode filter values through their field schemas, decode all model fields without exposing internal columns, and read all matching groups in one statement. Results are ordered by partition columns ascending, then by the declared ordering within each partition.
230
+
231
+ Choose `latestPerGroup` for up to N complete rows per group. PostgreSQL `DISTINCT ON` is useful when you need exactly one representative row per nonempty group; `latestPerGroup` with `limit: 1` covers that case too. Use `aggregate` for grouped summaries such as time per file, ordered to show the slowest N groups:
232
+
233
+ ```ts
234
+ const slowestFiles = yield* results.aggregate({
235
+ groupBy: ["file"],
236
+ aggregates: {
237
+ total: { sum: "durationMs" },
238
+ tests: { count: "*" },
239
+ failed: { boolOr: "failed" },
240
+ },
241
+ orderBy: [{ field: "total", direction: "desc" }],
242
+ limit: 10,
243
+ });
244
+ ```
245
+
246
+ `groupBy` is a nonempty list of model fields. Each aggregate result name must differ from the group fields. `count: "*"` counts rows; `count: field` counts non-null values. `sum` and `avg` accept number or `BigDecimal` fields, `min` and `max` accept the same ordered fields as range filters, and `boolOr` and `boolAnd` accept boolean fields. Counts decode as numbers; sums and averages decode from text as the field's numeric type, min and max use the field schema, and boolean aggregates decode as booleans. Every aggregate except count is nullable for all-null groups. Group columns use their model schemas, and invalid results fail with `SchemaError`.
247
+
248
+ An optional `where` uses the `findMany` filters before grouping. `orderBy` is a list of group fields or aggregate result names, and `limit` is a nonnegative integer over the resulting groups. Unknown group, aggregate operand, filter or sort fields passed at runtime fail with `SchemaError` before SQL executes. Use native SQL for percentiles, `having`, joins and other queries outside this shape.
249
+
250
+ ### Copy query results into another table
251
+
252
+ To copy a query directly into another table, prepare a select and map each target insert field to a projected source field:
253
+
254
+ ```ts
255
+ const copied = yield* archive.insertFromSelect(
256
+ lines.select({
257
+ where: { amount: 52 },
258
+ select: ["name", "amount", "note"],
259
+ orderBy: { field: "name", direction: "asc" },
260
+ limit: 20,
261
+ }),
262
+ { name: "name", amount: "amount", note: "note" },
263
+ );
264
+ ```
265
+
266
+ Here `archive` is another unversioned `InvoiceLine` repository with its own table. `select` takes the same query options as `findMany` and prepares an Effect SQL fragment without reading rows into the application. `insertFromSelect(source, columns)` executes one `INSERT ... SELECT ... RETURNING` statement and returns Schema-decoded target model instances, including generated fields. The mapping requires all required target insert fields, allows optional insert fields, and checks source projection names plus decoded and encoded field types. Values move within SQL, so source decoding and target input encoding do not run; compatible schemas must describe compatible database storage.
267
+
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
+
270
+
271
+ ### Composite keys and idempotent writes
272
+
273
+ 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
+
275
+ ```ts
276
+ class Reading extends Model.Class<Reading>("Reading")({
277
+ source: Schema.String,
278
+ sequence: Schema.NumberFromString,
279
+ amount: Schema.NumberFromString,
280
+ label: Schema.String,
281
+ }) {}
282
+
283
+ const ingest = Effect.gen(function* () {
284
+ const readings = yield* makeRepository(Reading, {
285
+ tableName: "readings",
286
+ idColumn: ["source", "sequence"],
287
+ conflictColumns: ["source", "sequence"],
288
+ spanPrefix: "Reading",
289
+ });
290
+ const input = { source: "meter", sequence: 1, amount: 52, label: "Imported" };
291
+ const inserted = yield* readings.insertOrIgnore(input);
292
+ const saved = yield* readings.upsert(
293
+ { ...input, amount: 60 },
294
+ { updateColumns: ["amount"] },
295
+ );
296
+ const found = yield* readings.findById({ source: "meter", sequence: 1 });
297
+ return { inserted, saved, found };
298
+ });
299
+ ```
300
+
301
+ The application creates `readings` with `PRIMARY KEY (source, sequence)`; `sequence` and `amount` are text columns for these codecs. Composite keys contain at least two model fields. `findById` and `delete` take an object containing every key field and encode each through its field schema. `update` and `updateVoid` use the full key from the model's update input and leave the key columns unchanged. Missing key fields fail with `SchemaError` before SQL runs. A scalar `idColumn` keeps native CRUD signatures. Writes announce a composite row as the table name, a colon and a JSON array of the returned key values in `idColumn` order, such as `readings:["meter","1"]`; a PostgreSQL `bigint` key value appears as its decimal string.
302
+
303
+ `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
+
305
+ | Method | Input | Result |
306
+ | --- | --- | --- |
307
+ | `insertOrIgnore` | Model insert input | `Option.some` of the decoded inserted model, or `Option.none` when the declared key conflicts. |
308
+ | `upsert` | Model insert input and `{ updateColumns: [...] }` | The decoded inserted or updated model. |
309
+ | `insertManyOrIgnore` | Readonly array of model insert inputs | Decoded inserted models; conflicting rows are omitted. |
310
+ | `upsertMany` | Readonly array of model insert inputs and `{ updateColumns: [...] }` | Decoded inserted or updated models. |
311
+
312
+ 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
+
314
+ For a partial unique index such as `CREATE UNIQUE INDEX ON entries (a, b) WHERE kind = 'x'`, add `conflictWhere` beside `conflictColumns`:
315
+
316
+ ```ts
317
+ const entries = yield* makeRepository(Entry, {
318
+ tableName: "entries",
319
+ idColumn: "id",
320
+ conflictColumns: ["a", "b"],
321
+ conflictWhere: { kind: "x" },
322
+ spanPrefix: "Entry",
323
+ });
324
+ const inserted = yield* entries.insertManyOrIgnore(inputs);
325
+ ```
326
+
327
+ All four conflict methods use this target, producing `ON CONFLICT (a, b) WHERE kind = $n` before `DO NOTHING` or `DO UPDATE SET ...`, followed by `RETURNING`. `conflictWhere` uses typed equality entries like `findMany.where`: decoded field values pass through their field codecs and remain bound parameters. Rows outside the index predicate can insert with the same key. A target that matches no unique index fails with native `SqlError` in the error channel. Use native `SqlClient` and `SqlSchema` for SQL expressions or other conflict policies.
328
+
329
+ On PostgreSQL, pass `updateWhere` to conditionally apply the update branch:
330
+
331
+ ```ts
332
+ const result = yield* pullRequests.upsert(incoming, {
333
+ updateColumns: ["title", "updated_at"],
334
+ updateWhere: { updated_at: { lt: { excluded: "updated_at" } } },
335
+ });
336
+ ```
337
+
338
+ `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
+
340
+ With `updateWhere`, the typed result is `{ _tag: "Inserted", row }`, `{ _tag: "Updated", row }` or `{ _tag: "Unchanged" }`. Changed rows are decoded models; unchanged results carry no row and announce no change keys. Omitting `updateWhere` keeps the model result. `UpsertFilter` and `UpsertResult` are exported from `@shivaedev/sql/conditionalUpsert.ts`.
341
+
342
+ `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
+
344
+ Versioned repositories with `conflictColumns` offer `insertOrIgnore`, including optional `conflictWhere`; other conflict writes remain unavailable because they would overwrite rows without comparing versions. `versionColumn` accepts a scalar or composite `idColumn`.
345
+
346
+ ### Batch writes
347
+
348
+ Every unversioned repository also offers `insertMany(inputs)`, without requiring `conflictColumns`. Each batch method accepts a readonly array of decoded model insert inputs. Unconditional batch methods return an array of decoded models; conditional upserts return the tagged outcomes described above. Empty inputs return `[]` without writing.
349
+
350
+ ```ts
351
+ const saved = yield* readings.upsertMany(
352
+ [
353
+ { source: "meter", sequence: 1, amount: 60, label: "Imported" },
354
+ { source: "meter", sequence: 2, amount: 75, label: "Imported" },
355
+ ],
356
+ { updateColumns: ["amount"] },
357
+ );
358
+ ```
359
+
360
+ Unconditional batch writes use one `INSERT ... RETURNING` statement per chunk, with at most 1,000 rows and 10,000 bind parameters per statement, below PostgreSQL's 65,535 parameter limit. Missing optional fields use SQL `DEFAULT`; an explicit null stays null. Return values contain the rows returned by the database, with ignored conflicts omitted. PostgreSQL's native conflict rules apply within each chunk, including an error when an upsert tries to update the same stored row twice.
361
+
362
+ Each batch call starts `transact`, so a later chunk failure rolls back earlier chunks. A surrounding `transact` groups several calls into one commit and one deduplicated change batch.
363
+
364
+ ### Optimistic concurrency
365
+
366
+ Declare a numeric version field in the model's select and update variants, leave it out of insert, and select it with `versionColumn`:
367
+
368
+ ```ts
369
+ class Document extends Model.Class<Document>("Document")({
370
+ id: Schema.String,
371
+ ownerId: Schema.String,
372
+ name: Schema.String,
373
+ lockVersion: Model.Field({ select: Schema.Number, update: Schema.Number, json: Schema.Number }),
374
+ }) {}
375
+
376
+ const makeDocuments = makeRepository(Document, {
377
+ tableName: "documents",
378
+ idColumn: ["id", "ownerId"],
379
+ conflictColumns: ["id"],
380
+ spanPrefix: "Document",
381
+ versionColumn: "lockVersion",
382
+ });
383
+
384
+ const reviseDocument = Effect.gen(function* () {
385
+ const documents = yield* makeDocuments;
386
+ const key = { id: crypto.randomUUID(), ownerId: "current-owner" };
387
+ yield* documents.insertOrIgnore({ ...key, name: "Draft" });
388
+ const original = yield* documents.findById(key);
389
+ const saved = yield* documents.update({ ...original, name: "Published" });
390
+ yield* documents.delete(key, saved.lockVersion);
391
+ });
392
+ ```
393
+
394
+ The application creates the integer version column; no database default is required. `insert`, `insertVoid` and `insertOrIgnore` initialize it to `0`. The version field must encode and decode numbers without codec services, be required in updates, and differ from every primary key column. The example uses a unique `id` constraint so a retried creation returns `Option.none()` without reading or changing the existing row.
395
+
396
+ `update` and `updateVoid` treat the input's version as the version the caller read. Their single SQL statement matches every key column and the expected version and increments the stored version by one. With the composite key above, every write includes the owner. `update` returns the decoded model with the new version. `delete(key, expectedVersion)` matches every key column and the expected version before removing the row; it requires the expected version for a versioned repository.
397
+
398
+ A stale version or missing row fails with `OptimisticConcurrencyError` in the error channel for `update`, `updateVoid` and `delete`. Import it from `@shivaedev/sql/repository.ts`; its fields are `tableName`, `id`, `expectedVersion` and `reason: "missing" | "stale"`. For a composite key, `id` holds the full key object. Only a zero-row write triggers a read under that same full key to classify the failure: an absent key is `missing`, an existing key is `stale`. A row under another owner is `missing`, with no stored version or row data exposed. Two concurrent writers using the same read version produce one successful update and one conflict, and the losing write leaves the winner's data intact.
399
+
400
+ These writes use SQL `RETURNING`. Repositories without `versionColumn` retain native CRUD signatures and behavior.
401
+
402
+ ### Transactions and invalidation
403
+
404
+ ```ts
405
+ import { Data } from "effect";
406
+ import { transact } from "@shivaedev/sql/transact.ts";
407
+
408
+ class StorageUnavailable extends Data.TaggedError("StorageUnavailable")<{
409
+ readonly reason: string;
410
+ }> {}
411
+
412
+ const saveInvoiceLine = (input: typeof InvoiceLine.update.Type) =>
413
+ Effect.gen(function* () {
414
+ const lines = yield* makeInvoiceLines;
415
+ const saved = yield* lines.update(input);
416
+ return saved;
417
+ }).pipe(
418
+ transact({
419
+ onSqlError: (error) => new StorageUnavailable({ reason: error.message }),
420
+ }),
421
+ );
422
+ ```
423
+
424
+ `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
+
426
+ Repository writes returning more than 1,000 rows announce the table key and `<table>:*` instead of per-row item keys, counted across all chunks of each call. Calls returning up to 1,000 rows keep their table and item keys. Effect-contract item reads register the collection-wide key automatically; code registering raw `<table>:<id>` Reactivity keys must also register `<table>:*`. Command invalidation keys are unchanged.
427
+
428
+ The transaction rules follow the database owner:
429
+
430
+ - A typed failure or interruption before commit rolls back and announces nothing. A failed deferred `COMMIT` fails through `onSqlError` and also announces nothing.
431
+ - 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
+ - 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.
433
+ - Keys belong to the `SqlClient` in the recording Effect's context. Entering another client's transaction does not move an existing client's keys. Re-entering a still-open transaction on the original client joins its own savepoint.
434
+ - Recording from a fiber after its transaction finishes dies. Record every changed key before the body returns.
435
+
436
+ Begin the outermost changed transaction with `transact`. Both `transact` and `invalidateOnCommit` refuse a native outer `sql.withTransaction` they do not own, because they cannot observe its commit. Outside a transaction, `invalidateOnCommit` publishes immediately.
437
+
438
+ PostgreSQL commit visibility decides publication even when a caller is interrupted while `COMMIT` is in flight: if the database commits, keys publish and the caller remains interrupted. If invalidation itself fails after commit, the failure is logged and the committed result stands. Do not treat a notification failure as evidence that the write can be retried safely.
439
+
440
+ For the generic ownership mechanism, read [commit-bound changes](https://github.com/ShivaeDev/platform/blob/main/docs/framework/changes.md). Reactivity keys are local reader invalidation; an application's cross-process or browser delivery belongs at its delivery boundary.
441
+
442
+ ### PostgreSQL advisory locks
443
+
444
+ Serialize cooperating jobs inside `transact`:
445
+
446
+ ```ts
447
+ import { advisoryXactLock } from "@shivaedev/sql/advisoryXactLock.ts";
448
+
449
+ const serializeInvoiceJob = (input: typeof InvoiceLine.update.Type) => Effect.gen(function* () {
450
+ yield* advisoryXactLock("jobs:invoice-import");
451
+ yield* saveInvoiceLine(input);
452
+ }).pipe(
453
+ transact({
454
+ onSqlError: (error) => new StorageUnavailable({ reason: error.message }),
455
+ }),
456
+ );
457
+ ```
458
+
459
+ `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.
460
+
461
+ ### PostgreSQL migrations: one discovered folder
462
+
463
+ Application migrations live in `src/server/migrations/`. Name each file with a four-digit zero-padded sequential number, an underscore and a snake_case name, such as `0007_add_note_tags.ts`. Export the migration's SQL Effect as the default export:
464
+
465
+ ```ts
466
+ import { Effect } from "effect";
467
+ import { SqlClient } from "effect/sql";
468
+
469
+ export default Effect.gen(function* () {
470
+ const sql = yield* SqlClient.SqlClient;
471
+ yield* sql`create table orders (name text primary key)`;
472
+ });
473
+ ```
474
+
475
+ Run the discovered files from the application root:
476
+
477
+ ```ts
478
+ import { NodeServices } from "@effect/platform-node";
479
+ import { Effect } from "effect";
480
+ import { migrationsFromDirectory } from "@shivaedev/sql/migrationsFromDirectory.ts";
481
+ import { migratePostgres } from "@shivaedev/sql/migrations.ts";
482
+
483
+ const migrateOrders = migratePostgres({
484
+ table: "app_migrations",
485
+ lockTimeout: "5 seconds",
486
+ loader: migrationsFromDirectory(process.cwd()).pipe(Effect.provide(NodeServices.layer)),
487
+ });
488
+ ```
489
+
490
+ `migrationsFromDirectory(root)` delegates discovery to Effect's `Migrator.fromFileSystem` and requires native `FileSystem` and `Path` services. Keep the folder beside the server source when deploying it, or include it in your server build. Adding a migration means adding one file; there is no import list. The first run of `0001_create_orders.ts` returns `[[1, "create_orders"]]`; repeating it returns `[]`, and adding a higher numbered file applies the new work. Treat applied migrations as immutable.
491
+
492
+ Use sequential numbers, not timestamps. Effect's Migrator skips any migration whose ID is not greater than the latest applied ID. A timestamped migration from a branch that merges late could therefore be silently skipped by native Migrator on an already-migrated database. Two branches that choose the same sequential number instead fail loudly with Effect's duplicate-ID `MigrationError` (`kind: "Duplicates"`), forcing the second branch to renumber and re-check. This package also rejects gaps below the latest applied ID, as described below.
493
+
494
+ `migrationConvention.ts` exports `MIGRATIONS_DIRECTORY` and `MIGRATION_FILE_PATTERN` so template packaging and repository quality configuration share the folder and filename convention.
495
+
496
+ `migratePostgres` rejects loaded migration IDs at or below the latest applied ID that are missing from the ledger with a typed native `MigrationError` (`kind: "BadState"`) naming those IDs.
497
+
498
+ `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
+
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` defect. 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
+
502
+ 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
+
504
+ 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.
505
+
506
+ 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.
507
+
508
+ 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
+
510
+ Server's `appDatabase` 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. jobs reads the service automatically; applications do not connect a signal themselves. Web's `"check"` mode ignores the marker.
511
+
512
+
513
+ ### PostgreSQL delivery between processes
514
+
515
+ `postgresChanges` from `@shivaedev/sql/postgresChanges.ts` returns a scoped Layer for the `transact` channel. Provide it to every process that writes, including job workers. Every server uses the same database and channel, its own native PostgreSQL client and its own Reactivity service. Create the server's `makeLiveServer` with that same Reactivity service and supply its `resync` Effect to the binding.
516
+
517
+ ```ts
518
+ const delivery = postgresChanges({
519
+ channel: "application_changes",
520
+ client: pg,
521
+ resync: hints.resync,
522
+ });
523
+ ```
524
+
525
+ `pg` is the application's `@effect/sql-pg` client; the binding accepts its native SQL and `listen` methods without creating another pool. Reserve one session connection for LISTEN and another for writes and heartbeats; use a pool client with at least two connections. 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`.
526
+
527
+ `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 LISTEN. A missing heartbeat or a stream failure resyncs streams before listener cleanup; a successful reconnection resyncs again. Each listener finalizer has the same `heartbeatTimeout` deadline, so a stalled UNLISTEN cannot prevent the following native connection release or a fresh subscription. 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.
528
+
529
+ 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.
530
+
531
+ ### API
532
+
533
+ Import the module that defines the name; there is no root entry.
534
+
535
+ | Module | Export | Input or role |
536
+ | --- | --- | --- |
537
+ | `@shivaedev/sql/clientOptions.ts` | `clientOptions(options, { snakeCase? })`, `SqlNameTransforms` | Add default native name transforms to driver or app host options; opt out with `snakeCase: false`. |
538
+ | `@shivaedev/sql/repository.ts` | `makeRepository(model, options)` | `tableName`, scalar or composite `idColumn`, `spanPrefix`, optional `versionColumn`, `conflictColumns` and `conflictWhere`; requires native `SqlClient`. |
539
+ | `@shivaedev/sql/repository.ts` | `OptimisticConcurrencyError` | Typed stale or missing-row failure for versioned writes, with `tableName`, `id`, `expectedVersion`, `reason: "missing" | "stale"`. |
540
+ | `@shivaedev/sql/transact.ts` | `transact(effect, options)` or `effect.pipe(transact(options))` | Required `onSqlError` mapper; requires `SqlClient` and `Reactivity`. |
541
+ | `@shivaedev/sql/transact.ts` | `invalidateOnCommit(keys)` | Records keys for its client, or invalidates immediately outside a transaction. |
542
+ | `@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`. |
543
+ | `@shivaedev/sql/migrationConvention.ts` | `MIGRATIONS_DIRECTORY`, `MIGRATION_FILE_PATTERN` | Application folder and four-digit sequential snake_case TypeScript filename pattern. |
544
+ | `@shivaedev/sql/migrationsFromDirectory.ts` | `migrationsFromDirectory(root)` | Discover migrations under the application root with native `FileSystem` and `Path`. |
545
+ | `@shivaedev/sql/migrations.ts` | `migratePostgres(options)` | Native loader, required `lockTimeout`, optional `components` and ledger `table`, default `effect_sql_migrations`. |
546
+ | `@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. |
547
+ | `@shivaedev/sql/pendingPostgresMigrations.ts` | `pendingPostgresMigrations({ loader, table?, components? })` | Pending ledger migrations as ID and name, and pending schema components as name and detail. |
548
+ | `@shivaedev/sql/postgresChanges.ts` | `postgresChanges(options)` | Scoped PostgreSQL LISTEN/NOTIFY Layer for commit-bound invalidation. |
549
+
550
+ ### Install and database setup
551
+
552
+ ```sh
553
+ pnpm add @shivaedev/sql effect@4.0.5 @effect/sql-pg@4.0.5
554
+ ```
555
+
556
+ `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`:
557
+
558
+ ```ts
559
+ import * as PgClient from "@effect/sql-pg/PgClient";
560
+ import { Layer, Redacted } from "effect";
561
+ import * as Reactivity from "effect/reactivity/Reactivity";
562
+ import { clientOptions } from "@shivaedev/sql/clientOptions.ts";
563
+
564
+ const database = Layer.merge(
565
+ PgClient.layer(clientOptions({ url: Redacted.make("postgresql://localhost/example") })),
566
+ Reactivity.layer,
567
+ );
568
+
569
+ const save = saveInvoiceLine({
570
+ id: 1,
571
+ name: "Consulting",
572
+ amount: 52,
573
+ note: null,
574
+ }).pipe(Effect.provide(database));
575
+ ```
576
+
577
+ The application's startup runs migrations and keeps its database scope alive for the services that use it. The example assumes the table and row exist. Models describe expected data; they do not create tables or establish authorization policy.
578
+
579
+ ### Errors and storage limits
580
+
581
+ 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
+
583
+ Map a known constraint at the operation that understands it, after the transaction has rolled back:
584
+
585
+ ```ts
586
+ import type { SqlError } from "effect/sql/SqlError";
587
+
588
+ class NameTaken extends Data.TaggedError("NameTaken") {}
589
+
590
+ const knownNameConflict = (error: SqlError): Effect.Effect<never, NameTaken | SqlError> =>
591
+ error.reason._tag === "UniqueViolation" &&
592
+ error.reason.constraint === "invoice_line_name_key"
593
+ ? Effect.fail(new NameTaken())
594
+ : Effect.fail(error);
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));
602
+ });
603
+ ```
604
+
605
+ The migration owns the constraint name `invoice_line_name_key`. The mapper recognizes that named unique violation and keeps other failures intact. Put recovery outside the transaction so earlier writes in the operation roll back too. Domain error presentation belongs to the application.
606
+
607
+ 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
+
609
+ JSON and JSONB columns are not supported. A repository write of a plain object to one fails with `SqlError` and writes nothing. Give structured data its own columns, or its own table when it repeats, so PostgreSQL constraints and the model both describe it.
610
+
611
+ `timestamptz` preserves an instant at millisecond precision; `timestamp without time zone` holds UTC wall time, whatever the Node process time zone. A model cannot recover microseconds the driver has discarded. See [PostgreSQL codec cases and limits](https://github.com/ShivaeDev/platform/blob/main/docs/framework/postgres-codecs.md) before choosing a storage representation.
612
+
613
+ 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
+
615
+ The `withChangeCoverage(body)` story verb from `testing.ts` checks PostgreSQL transaction write counters before commit and fails with `UnannouncedWrites` for tables without a recorded table key. See [automatic change delivery](https://github.com/ShivaeDev/platform/blob/main/docs/framework/changes.md) for host defaults and coverage limits.
@@ -0,0 +1,9 @@
1
+ import { Effect } from "effect";
2
+ import { Migrator, type SqlClient } from "effect/sql";
3
+ export interface SchemaComponent<R = never> {
4
+ readonly apply: Effect.Effect<unknown, unknown, R | SqlClient.SqlClient>;
5
+ readonly name: string;
6
+ readonly pending: Effect.Effect<string | undefined, unknown, R | SqlClient.SqlClient>;
7
+ }
8
+ export declare function runSchemaComponent<A, R>(name: string, effect: Effect.Effect<A, unknown, R>): Effect.Effect<A, Migrator.MigrationError, R>;
9
+ //# sourceMappingURL=SchemaComponent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SchemaComponent.d.ts","sourceRoot":"","sources":["../src/SchemaComponent.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AAEtD,MAAM,WAAW,eAAe,CAAC,CAAC,GAAG,KAAK;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,CAAC,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC;CACtF;AAED,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,gDAE1F"}