@zio.dev/zio-blocks 0.0.51 → 0.0.55

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 (164) hide show
  1. package/adr/2026-07-18-data-migration.md +123 -0
  2. package/guides/async-getting-started.md +687 -0
  3. package/guides/compile-time-resource-safety-with-scope.md +6 -0
  4. package/guides/getting-started-with-mux.md +0 -112
  5. package/guides/query-dsl-extending.md +1 -1
  6. package/guides/query-dsl-fluent-builder.md +1 -1
  7. package/guides/query-dsl-reified-optics.md +1 -1
  8. package/guides/query-dsl-sql.md +395 -1
  9. package/guides/sql-checked-interpolation.md +173 -0
  10. package/guides/sql-transactions.md +286 -0
  11. package/guides/telemetry-guide.md +131 -70
  12. package/guides/zio-schema-migration.md +6 -6
  13. package/index.md +200 -583
  14. package/package.json +1 -1
  15. package/reference/async.md +1379 -531
  16. package/reference/chunk.md +3 -3
  17. package/reference/codegen/index.md +1 -1
  18. package/reference/combinators.md +4 -4
  19. package/reference/config/config-decoder.md +460 -0
  20. package/reference/config/config-source.md +489 -0
  21. package/reference/config/errors.md +278 -0
  22. package/reference/config/flags.md +369 -0
  23. package/reference/config/formats.md +314 -0
  24. package/reference/config/index.md +304 -0
  25. package/reference/config/rollout.md +336 -0
  26. package/reference/context.md +6 -49
  27. package/reference/data-migration.md +269 -0
  28. package/reference/datastar/attributes.md +302 -0
  29. package/reference/datastar/events.md +234 -0
  30. package/reference/datastar/index.md +256 -0
  31. package/reference/datastar/signals.md +230 -0
  32. package/reference/datastar/sse.md +295 -0
  33. package/reference/datastar.md +2 -2
  34. package/reference/docs.md +2 -2
  35. package/reference/endpoint/bulk-creation.md +96 -0
  36. package/reference/endpoint/index.md +9 -89
  37. package/reference/endpoint/path-codec.md +12 -24
  38. package/reference/endpoint/route-pattern.md +4 -6
  39. package/reference/endpoint/segment-codec.md +19 -32
  40. package/reference/html.md +313 -9
  41. package/reference/htmx/index.md +4 -52
  42. package/reference/htmx/response-headers.md +240 -0
  43. package/reference/http-model/headers.md +735 -0
  44. package/reference/http-model/index.md +3 -1
  45. package/reference/http-model/model.md +107 -71
  46. package/reference/http-model/schema-codecs.md +522 -0
  47. package/reference/http-model/schema.md +6 -3
  48. package/reference/http-model/server-sent-event.md +341 -0
  49. package/reference/jwt.md +195 -0
  50. package/reference/maybe.md +128 -11
  51. package/reference/media-type.md +2 -2
  52. package/reference/mux.md +254 -0
  53. package/reference/mux.mdx +7 -2
  54. package/reference/openapi.md +3 -3
  55. package/reference/projection.md +654 -0
  56. package/reference/resource-management/resource.md +2 -98
  57. package/reference/resource-management/scope.md +1 -209
  58. package/reference/resource-management/wire.md +4 -50
  59. package/reference/ringbuffer/advanced.mdx +1 -1
  60. package/reference/ringbuffer/index.mdx +3 -3
  61. package/reference/ringbuffer/mpmc.mdx +38 -4
  62. package/reference/ringbuffer/mpsc.mdx +36 -4
  63. package/reference/ringbuffer/spmc.mdx +1 -1
  64. package/reference/ringbuffer/spsc.mdx +87 -15
  65. package/reference/schema/allows.md +0 -96
  66. package/reference/schema/binding.md +2 -2
  67. package/reference/schema/built-in-codecs/avro.md +2 -2
  68. package/reference/schema/built-in-codecs/bson.md +50 -20
  69. package/reference/schema/built-in-codecs/csv.md +2 -2
  70. package/reference/schema/built-in-codecs/index.md +3 -3
  71. package/reference/schema/built-in-codecs/json/index.md +2 -2
  72. package/reference/schema/built-in-codecs/messagepack.md +3 -3
  73. package/reference/schema/built-in-codecs/thrift.md +2 -2
  74. package/reference/schema/built-in-codecs/toon.md +3 -3
  75. package/reference/schema/built-in-codecs/yaml.md +2 -2
  76. package/reference/schema/codec.md +11 -11
  77. package/reference/schema/dynamic-optic.md +48 -3
  78. package/reference/schema/dynamic-schema.md +3 -3
  79. package/reference/schema/index.md +2 -0
  80. package/reference/schema/path-interpolator.md +2 -0
  81. package/reference/schema/reflect-transformer.md +140 -0
  82. package/reference/schema/schema-evolution/as.md +4 -4
  83. package/reference/schema/schema-evolution/into.md +2 -2
  84. package/reference/schema/schema-expr.md +2 -2
  85. package/reference/schema/schema-search.md +263 -0
  86. package/reference/schema/schema.md +10 -2
  87. package/reference/schema/type-class-derivation.md +1 -1
  88. package/reference/smithy.md +502 -3
  89. package/reference/sql/db-codec-deriver.md +3 -3
  90. package/reference/sql/db-codec.md +22 -22
  91. package/reference/sql/db-con.md +4 -4
  92. package/reference/sql/db-connection.md +1 -1
  93. package/reference/sql/db-param.md +1 -1
  94. package/reference/sql/db-result-reader.md +4 -2
  95. package/reference/sql/db-tx.md +46 -14
  96. package/reference/sql/ddl.md +1 -1
  97. package/reference/sql/frag.md +44 -10
  98. package/reference/sql/index.md +7 -7
  99. package/reference/sql/repo.md +15 -15
  100. package/reference/sql/sql-dialect.md +1 -1
  101. package/reference/sql/sql-logger.md +1 -1
  102. package/reference/sql/sql-name-mapper.md +3 -3
  103. package/reference/sql/table-metadata.md +3 -3
  104. package/reference/sql/table.md +10 -10
  105. package/reference/sql/transactor-zio.md +1 -1
  106. package/reference/sql/transactor.md +21 -11
  107. package/reference/sql-zio.md +1 -1
  108. package/reference/streams/core/index.md +32 -0
  109. package/reference/streams/{pipeline.md → core/pipeline.md} +210 -74
  110. package/reference/streams/{sink.md → core/sink.md} +331 -353
  111. package/reference/streams/{stream.md → core/stream.md} +919 -209
  112. package/reference/streams/execution-and-compatibility/async-execution.md +822 -0
  113. package/reference/streams/execution-and-compatibility/index.md +35 -0
  114. package/reference/streams/execution-and-compatibility/platform-differences.md +297 -0
  115. package/reference/streams/execution-and-compatibility/scala-2-compatibility.md +88 -0
  116. package/reference/streams/execution-and-compatibility/zero-boxing.md +393 -0
  117. package/reference/streams/index.md +140 -67
  118. package/reference/streams/primitives/index.md +30 -0
  119. package/reference/streams/primitives/reader.md +1992 -0
  120. package/reference/streams/{writer.md → primitives/writer.md} +254 -98
  121. package/reference/telemetry/common/any-value.md +90 -0
  122. package/reference/telemetry/common/attribute-key.md +87 -0
  123. package/reference/telemetry/common/attributes.md +118 -0
  124. package/reference/telemetry/common/index.md +39 -0
  125. package/reference/telemetry/common/instrumentation-scope.md +24 -0
  126. package/reference/telemetry/common/resource.md +34 -0
  127. package/reference/telemetry/index.md +311 -0
  128. package/reference/telemetry/logging/index.md +197 -0
  129. package/reference/telemetry/logging/log-enrichment.md +72 -0
  130. package/reference/telemetry/logging/log-formatter.md +100 -0
  131. package/reference/telemetry/logging/log-record-processor.md +56 -0
  132. package/reference/telemetry/logging/log-record.md +44 -0
  133. package/reference/telemetry/logging/log-writer.md +64 -0
  134. package/reference/telemetry/logging/logger-provider.md +142 -0
  135. package/reference/telemetry/logging/logger.md +83 -0
  136. package/reference/telemetry/logging/severity.md +62 -0
  137. package/reference/telemetry/metrics/index.md +150 -0
  138. package/reference/telemetry/metrics/instruments.md +183 -0
  139. package/reference/telemetry/metrics/labeled-instruments.md +74 -0
  140. package/reference/telemetry/metrics/meter-provider.md +76 -0
  141. package/reference/telemetry/metrics/meter.md +98 -0
  142. package/reference/telemetry/metrics/metric-data.md +57 -0
  143. package/reference/telemetry/otel/custom-exporter.md +216 -0
  144. package/reference/telemetry/otel/index.md +212 -0
  145. package/reference/telemetry/tracing/index.md +155 -0
  146. package/reference/telemetry/tracing/sampler.md +89 -0
  147. package/reference/telemetry/tracing/span-builder.md +57 -0
  148. package/reference/telemetry/tracing/span-context.md +39 -0
  149. package/reference/telemetry/tracing/span-data.md +32 -0
  150. package/reference/telemetry/tracing/span-kind.md +55 -0
  151. package/reference/telemetry/tracing/span-processor.md +53 -0
  152. package/reference/telemetry/tracing/span-status.md +47 -0
  153. package/reference/telemetry/tracing/span.md +117 -0
  154. package/reference/telemetry/tracing/tracer-provider.md +91 -0
  155. package/reference/telemetry/tracing/tracer.md +52 -0
  156. package/reference/typeid.md +0 -64
  157. package/sidebars.js +150 -12
  158. package/undocumented-report.md +528 -270
  159. package/reference/config.md +0 -158
  160. package/reference/streams/concurrent-operators.md +0 -106
  161. package/reference/streams/reader.md +0 -1284
  162. package/reference/streams/scala-2-compatibility.md +0 -55
  163. package/reference/streams/zero-boxing.md +0 -275
  164. package/reference/telemetry.md +0 -693
@@ -0,0 +1,269 @@
1
+ ---
2
+ id: data-migration
3
+ title: "SQL Data Migration"
4
+ ---
5
+
6
+ The `zio.blocks.data.migration` module provides three execution models for evolving database schemas online. It builds on [`Migration[A, B]`](./schema/migration.md) for the typed row transformation and [`Repo[E, ID]`](./sql/index.md) for database access. No hand-written SQL, no XML, no Liquibase-style migration files.
7
+
8
+ ## Overview
9
+
10
+ Data migration in ZIO Blocks is split into three execution tiers, each suited to a different scale of change:
11
+
12
+ | Model | Class | When to use |
13
+ |---|---|---|
14
+ | **A-Tiny** | `TinyMigrator` | DDL-only changes at startup (add column, rename table) |
15
+ | **A-Small** | `SmallMigrator` | Queue-based batch backfill for moderate tables |
16
+ | **B-Large** | `LargeMigrator` | Incremental worker with pause/resume and safe cutover for large tables |
17
+
18
+ All three share the same building blocks:
19
+
20
+ - `Migration[A, B]` from `zio.blocks.schema.migration` defines the typed transformation
21
+ - `Repo[E, ID]` from `zio.blocks.sql` provides database access for source and target
22
+ - `TargetStrategy` controls whether rows are updated in place or written to a shadow table
23
+ - `QueueTable` tracks dirty keys that need processing
24
+
25
+ The module is Scala 3 only.
26
+
27
+ ## Execution Models
28
+
29
+ ### A-Tiny: `TinyMigrator`
30
+
31
+ `TinyMigrator` runs DDL migrations at application startup through `Transactor.transact`. Extend it and override `run()` to issue schema changes:
32
+
33
+ ```scala
34
+ import zio.blocks.data.migration._
35
+ import zio.blocks.sql._
36
+
37
+ class AddAgeColumn(transactor: Transactor) extends TinyMigrator(transactor) {
38
+ def run()(using tx: DbTx): Unit =
39
+ // DDL statements executed at startup
40
+ ???
41
+ }
42
+
43
+ new AddAgeColumn(transactor).migrate()
44
+ ```
45
+
46
+ Tiny migrations are appropriate when the schema change is fast, non-blocking, and can run before the application starts serving requests.
47
+
48
+ ### A-Small: `SmallMigrator`
49
+
50
+ `SmallMigrator` is a queue-based batch worker for moderate-sized tables. It reads dirty keys from a `QueueTable`, fetches the source row, applies the `Migration[A, B]`, and writes the result to the target.
51
+
52
+ The lifecycle has three steps:
53
+
54
+ 1. `init()` prepares the target table (creates a shadow table for `ShadowTable` strategy)
55
+ 2. `processBatch()` claims and processes a batch of dirty keys
56
+ 3. `complete()` finalizes the migration (swaps shadow table if applicable)
57
+
58
+ Call `processBatch()` in a loop until the queue is empty, then call `complete()` (`complete()` refuses to run while items are still pending):
59
+
60
+ ```scala
61
+ import zio.blocks.data.migration._
62
+
63
+ // Assuming smallMigrator is already constructed (see full example below)
64
+ smallMigrator.init()
65
+
66
+ // Process batches until the queue drains
67
+ var pending = true
68
+ while (pending) {
69
+ val count = smallMigrator.processBatch()
70
+ pending = count > 0
71
+ }
72
+
73
+ smallMigrator.complete()
74
+ ```
75
+
76
+ `SmallMigrator` supports both `TargetStrategy.InPlace` and `TargetStrategy.ShadowTable`.
77
+
78
+ ### B-Large: `LargeMigrator`
79
+
80
+ `LargeMigrator` is an incremental worker designed for large tables where a full backfill would take too long. It adds pause/resume capability, concurrent worker support on PostgreSQL via `SKIP LOCKED`, and a safe completion protocol that guarantees no data is lost during cutover.
81
+
82
+ The lifecycle has four steps:
83
+
84
+ 1. `init()` prepares the target table (creates a shadow table for `ShadowTable` strategy)
85
+ 2. `fence()` signals cutover and stops new producers from writing to the old table
86
+ 3. `drain()` processes all remaining queue items until the queue is empty
87
+ 4. `complete()` swaps the shadow table and finalizes the migration
88
+
89
+ ```scala
90
+ import zio.blocks.data.migration._
91
+
92
+ // Assuming largeMigrator is already constructed (see full example below)
93
+ largeMigrator.init()
94
+
95
+ // ... application runs, dirty keys accumulate ...
96
+
97
+ // Cutover: stop writers, drain remaining, swap
98
+ largeMigrator.fence()
99
+ val totalDrained = largeMigrator.drain()
100
+ largeMigrator.complete()
101
+ ```
102
+
103
+ `LargeMigrator` is typically used with `TargetStrategy.ShadowTable` for safe cutover. The completion protocol ensures every dirty key is processed before the shadow table replaces the source.
104
+
105
+ ## Target Strategies
106
+
107
+ `TargetStrategy` controls where migrated rows are written:
108
+
109
+ - **`TargetStrategy.InPlace`** updates rows directly in the source table. Suitable when the schema change is backward compatible and the table structure does not change.
110
+ - **`TargetStrategy.ShadowTable(suffix)`** creates a shadow table with the target schema, writes migrated rows there, and swaps the shadow table in at completion. The suffix is appended to the source table name to derive the shadow table name. Suitable when the schema change is not backward compatible or when you need a clean cutover.
111
+
112
+ ```scala
113
+ TargetStrategy.InPlace
114
+ TargetStrategy.ShadowTable("v2") // shadow table will be named: users_v2
115
+ ```
116
+
117
+ ## Safe Completion Protocol (B-Large)
118
+
119
+ `LargeMigrator` follows a stateful protocol to guarantee zero data loss during cutover. The protocol moves through four states:
120
+
121
+ ```
122
+ Initialized → Fenced → Drained → Completed
123
+ ```
124
+
125
+ ### `init()` (Initialized)
126
+
127
+ Prepares the target table. For `TargetStrategy.ShadowTable`, creates the shadow table. The source table continues to accept writes normally — producers are not interrupted. Workers call `init()` once before processing any batches.
128
+
129
+ ### `fence()` (Fenced)
130
+
131
+ Signals that cutover is starting. The application **must** stop writing to the source table before calling `fence()`. After fencing, `drain()` will process all remaining queue entries until the queue is confirmed empty. Writes arriving after `fence()` may not be migrated.
132
+
133
+ ### `drain()` (Drained)
134
+
135
+ Processes all remaining dirty keys until the queue is empty. Returns the total number of rows processed. Because fencing stopped new writes, the queue is guaranteed to reach zero.
136
+
137
+ ### `complete()` (Completed)
138
+
139
+ Swaps the shadow table to replace the source table. After completion, the migration is done and the target table is live. Trigger cleanup and queue table removal are not performed automatically — callers are responsible for dropping these after verifying the migration is complete.
140
+
141
+ The protocol ensures that every row modified between `init()` and `complete()` is migrated, even if the application crashes and restarts mid-migration. The queue table persists across restarts.
142
+
143
+ ## ID Type Flexibility
144
+
145
+ `LargeMigrator` and `SmallMigrator` accept separate `ID1` and `ID2` type parameters for the source and target repos, but construction requires evidence that they are the same type (`ID1 =:= ID2`):
146
+
147
+ ```scala
148
+ SmallMigrator[UserV1, UserV2, Long, Long](...)
149
+ LargeMigrator[UserV1, UserV2, Long, Long](...)
150
+ ```
151
+
152
+ The queue table stores V1 IDs (`ID1`). The `ID1` type must have a `DbCodec` instance available as a given. The equality constraint keeps delete propagation type-safe: when a source row disappears between dequeue and read, the same key is reinterpreted for the target repo via the type evidence rather than a cast. Migrations that change the primary key type are out of scope for the built-in migrators.
153
+
154
+ ## Queue Primitives
155
+
156
+ `QueueTable` is the dirty-key tracking mechanism shared by `SmallMigrator` and `LargeMigrator`. The queue table has three columns:
157
+
158
+ | Column | Type | Description |
159
+ |---|---|---|
160
+ | `id` | `TEXT NOT NULL PRIMARY KEY` | The primary key of the affected source row |
161
+ | `op` | `TEXT NOT NULL DEFAULT 'I'` | The operation type: `'I'` (insert), `'U'` (update), or `'D'` (delete) |
162
+ | `payload` | `TEXT` | JSON-serialized V1 row data, captured only for `'D'` operations on PostgreSQL |
163
+
164
+ Queue entries are coalesced by primary key. There are two ways keys enter the queue:
165
+
166
+ 1. **Manual enqueue** — the application calls `QueueTable.enqueue` for every row it writes (or for a batch of rows to backfill).
167
+ 2. **Capture triggers** — pass `captureTriggers = true` when constructing `SmallMigrator` or `LargeMigrator`; `init()` then installs triggers via `QueueTable.installTriggers` so every source INSERT/UPDATE/DELETE upserts the affected key in the writer's own transaction. Trigger installation is idempotent (PostgreSQL requires PG 14+ for `CREATE OR REPLACE TRIGGER`). Do not enable capture triggers when the migrator writes to the same physical table it captures from: the worker's own writes would re-enqueue processed keys forever.
168
+
169
+ For `INSERT` and `UPDATE`, only the key and operation type are stored. For `DELETE`, PostgreSQL additionally captures the full row as JSON via `row_to_json(OLD)::text` into the `payload` column; the payload is reserved for future delete-recovery and is not read by the current workers. SQLite does not support `row_to_json`, so its delete entries carry no payload.
170
+
171
+ | Operation | Description |
172
+ |---|---|
173
+ | `QueueTable.create` | Creates the queue table (id, op, payload columns) |
174
+ | `enqueue` | Inserts the ID into the queue |
175
+ | `dequeue` | Claims and removes a batch of IDs, returning `List[ID]` |
176
+ | `pending` | Returns the number of unprocessed keys |
177
+ | `installTriggers` | Installs capture triggers on the source table |
178
+
179
+ PostgreSQL uses `FOR UPDATE SKIP LOCKED` for concurrent worker claims. SQLite uses a single consumer with `BEGIN IMMEDIATE` and a busy timeout. Queue IDs are stored as TEXT, so batches are claimed in lexicographic order (`'10'` before `'9'`); this affects claim order only, never correctness.
180
+
181
+ A worker claims the dirty key and, in one transaction, looks up the source row by ID, applies the `Migration[A, B]`, and writes the result to the target. If the source row is missing at processing time (deleted between enqueue and dequeue), the corresponding target row is deleted — `findAll` returns only rows that still exist.
182
+
183
+ ## Database Support
184
+
185
+ | Feature | PostgreSQL | SQLite |
186
+ |---|---|---|
187
+ | `TinyMigrator` | Yes | Yes |
188
+ | `SmallMigrator` | Yes | Yes |
189
+ | `LargeMigrator` | Yes (multi-worker) | Yes (single-worker) |
190
+ | `SKIP LOCKED` | Yes | No |
191
+ | `ShadowTable` (CREATE TABLE LIKE, atomic swap) | Yes | No |
192
+ | `InPlace` updates | Yes | Yes |
193
+
194
+ PostgreSQL supports the full feature set including concurrent workers, shadow table creation via `CREATE TABLE LIKE`, and atomic DDL swap. SQLite supports queue primitives and in-place updates but does not support shadow tables or concurrent workers.
195
+
196
+ ## Contextual Requirements
197
+
198
+ `TinyMigrator`, `SmallMigrator`, and `LargeMigrator` require a `Transactor` as an implicit constructor parameter. `SmallMigrator` and `LargeMigrator` also require a `DbCodec[ID1]` given for the queue table's primary key column.
199
+
200
+ ## Full Example
201
+
202
+ ```scala
203
+ import zio.blocks.schema._
204
+ import zio.blocks.schema.migration.Migration
205
+ import zio.blocks.sql.{DbCodec, DbCodecDeriver, Repo, Table, Transactor}
206
+ import zio.blocks.data.migration._
207
+
208
+ case class UserV1(id: Int, name: String)
209
+ case class UserV2(id: Int, name: String, age: Int)
210
+
211
+ object UserV1 { implicit val schema: Schema[UserV1] = Schema.derived }
212
+ object UserV2 { implicit val schema: Schema[UserV2] = Schema.derived }
213
+
214
+ val v1Table = Table.derived[UserV1]
215
+ val v2Table = Table.derived[UserV2]
216
+
217
+ implicit val codecInt: DbCodec[Int] = implicitly[Schema[Int]].deriving(DbCodecDeriver).derive
218
+ implicit val codecV1: DbCodec[UserV1] = UserV1.schema.deriving(DbCodecDeriver).derive
219
+ implicit val codecV2: DbCodec[UserV2] = UserV2.schema.deriving(DbCodecDeriver).derive
220
+
221
+ val v1Repo = Repo(v1Table, "id", summon[DbCodec[Int]], (_: UserV1).id)
222
+ val v2Repo = Repo(v2Table, "id", summon[DbCodec[Int]], (_: UserV2).id)
223
+
224
+ // Migration that transforms UserV1 to UserV2
225
+ val migration: Migration[UserV1, UserV2] = Migration
226
+ .newBuilder[UserV1, UserV2]
227
+ .addField(_.age, SchemaExpr.literal(0))
228
+ .build
229
+
230
+ // SmallMigrator: queue-based batch processing
231
+ val smallMigrator = SmallMigrator[UserV1, UserV2, Int, Int](
232
+ repoV1 = v1Repo,
233
+ repoV2 = v2Repo,
234
+ migration = migration,
235
+ queueTable = "user_migration_q",
236
+ batchSize = 100,
237
+ target = TargetStrategy.ShadowTable("v2")
238
+ )(using transactor, summon[DbCodec[Int]], Dialect.Postgres)
239
+
240
+ // Lifecycle: init, process batches, complete
241
+ smallMigrator.init()
242
+ // ... loop processBatch() ...
243
+ smallMigrator.complete()
244
+
245
+ // LargeMigrator: incremental worker with safe completion protocol
246
+ val largeMigrator = LargeMigrator[UserV1, UserV2, Int, Int](
247
+ repoV1 = v1Repo,
248
+ repoV2 = v2Repo,
249
+ migration = migration,
250
+ queueTable = "user_migration_q",
251
+ batchSize = 100,
252
+ target = TargetStrategy.ShadowTable("v2"),
253
+ captureTriggers = true
254
+ )(using transactor, summon[DbCodec[Int]], Dialect.Postgres)
255
+
256
+ // Safe lifecycle: init, fence, drain, complete
257
+ largeMigrator.init()
258
+ largeMigrator.fence()
259
+ val total = largeMigrator.drain()
260
+ largeMigrator.complete()
261
+ ```
262
+
263
+ ## Failure Policy
264
+
265
+ When a worker transaction fails, the transaction is rolled back and the dirty key is retained in the queue table. The failure is returned to the caller. No data is silently lost, and no automatic retry or dead-letter configuration is applied. The caller decides how to handle the failure.
266
+
267
+ ## Architecture Decisions
268
+
269
+ For a detailed record of architecture decisions, see the [ADR](../adr/2026-07-18-data-migration.md).
@@ -0,0 +1,302 @@
1
+ ---
2
+ id: attributes
3
+ title: "Datastar Attributes"
4
+ sidebar_label: "Attributes"
5
+ ---
6
+
7
+ `DatastarAttributes` supplies 27 `data*` helpers that make rendered HTML reactive. Each returns either a finished `Dom.Attribute` or a `DatastarAttrKey` awaiting a value, and `ToDatastarExpr` decides what may be assigned — rejecting raw `String` at compile time. The package object extends this trait, so one wildcard import brings every helper into scope. The two types every helper funnels through:
8
+
9
+ ```scala
10
+ final class DatastarAttrKey(val name: String) {
11
+ def :=[T](value: T)(implicit toDatastarExpr: ToDatastarExpr[T]): Dom.Attribute
12
+ }
13
+
14
+ trait ToDatastarExpr[-A] {
15
+ def toDatastarExpr(a: A): String
16
+ }
17
+ ```
18
+
19
+ ## Motivation
20
+
21
+ Datastar's whole interface is attribute names and expression strings. `data-text="$count"` displays a signal; `data-show="$count > 0"` conditionally renders; `data-class:active="$selected"` toggles a class. Getting one character wrong produces an attribute the framework ignores, with no error anywhere.
22
+
23
+ The failure that costs the most time is subtler than a typo. Attribute values are *expressions*, evaluated in the browser — `"$count"` reads a signal, but `"count"` is a string literal that happens to look right. Interpolating a Scala `String` into an expression position produces exactly that: valid HTML, ignored semantics, a blank element.
24
+
25
+ These helpers make the attribute name a method call and the value type-checked. There is no attribute name to misspell, and `ToDatastarExpr` is deliberately unavailable for `String`, so the one mistake that fails silently in the browser fails loudly at compile time instead.
26
+
27
+ ## Quick Showcase
28
+
29
+ Attributes compose with the HTML DSL like any other:
30
+
31
+ ```scala
32
+ import zio.blocks.html._
33
+ import zio.http.datastar._
34
+
35
+ val open = Signal[Boolean]("open")
36
+
37
+ val panel = div(
38
+ dataSignals(open := false),
39
+ button(dataOn.click := js"$open = !$open", "toggle"),
40
+ div(dataShow := open, dataClass("visible") := open, "panel contents")
41
+ )
42
+ ```
43
+
44
+ Rendering produces the attribute names and expressions Datastar expects:
45
+
46
+ ```scala
47
+ panel.renderMinified
48
+ // res0: String = "<div data-signals=\"{&quot;open&quot;: false}\"><button data-on:click=\"$open = !$open\">toggle</button><div data-show=\"$open\" data-class:visible=\"$open\">panel contents</div></div>"
49
+ ```
50
+
51
+ ## Attribute Naming
52
+
53
+ Two shapes appear in the rendered output, and which one a helper produces tells you whether it takes a key.
54
+
55
+ | Helper form | Renders as | Example |
56
+ | ----------- | ---------- | ------- |
57
+ | No key | `data-<name>` | `dataText` → `data-text` |
58
+ | Keyed | `data-<name>:<key>` | `dataClass("active")` → `data-class:active` |
59
+ | Own attribute | `data-<multi-word-name>` | `dataOnIntersect` → `data-on-intersect` |
60
+ | Modified | `…__<modifier>` | `dataOn.click.once` → `data-on:click__once` |
61
+
62
+ Keys derived from signal names are kebab-cased, so a `Signal[Int]("itemCount")` used as a `dataComputed` key becomes `data-computed:item-count`.
63
+
64
+ The third row is the one that breaks the pattern. A DOM event is a *key* on `data-on`, giving `data-on:click`, but the non-DOM triggers are attribute names in their own right — `data-on-intersect`, `data-on-interval`, `data-on-signal-patch` — with hyphens and no key. Their modifiers still attach with `__`.
65
+
66
+ ## Declaring State
67
+
68
+ Two helpers put signals on the page.
69
+
70
+ ### `dataSignals` — initial values
71
+
72
+ `dataSignals` has three forms. Given one or more `SignalUpdate`s it renders the whole object as the attribute value:
73
+
74
+ ```scala
75
+ import zio.blocks.html._
76
+ import zio.http.datastar._
77
+
78
+ val price = Signal[Double]("price")
79
+ val quantity = Signal[Int]("quantity")
80
+ ```
81
+
82
+ Several updates become a single `data-signals` attribute:
83
+
84
+ ```scala
85
+ div(dataSignals(price := 9.99, quantity := 1)).renderMinified
86
+ // res2: String = "<div data-signals=\"{&quot;price&quot;: 9.99, &quot;quantity&quot;: 1}\"></div>"
87
+ ```
88
+
89
+ Given a single `Signal`, it returns a `DataSignalsBuilder` for the keyed form — `data-signals:<name>` — which sets one signal rather than an object:
90
+
91
+ ```scala
92
+ div(dataSignals(price) := js"42.0").renderMinified
93
+ // res3: String = "<div data-signals:price=\"42.0\"></div>"
94
+ ```
95
+
96
+ The builder also carries the case modifiers described below. The third form, bare `dataSignals`, is a `DatastarAttrKey` for assigning a raw expression.
97
+
98
+ ### `dataComputed` — derived signals
99
+
100
+ `dataComputed(signal)` declares a signal whose value is an expression over others, recomputed by the browser whenever an input changes:
101
+
102
+ ```scala
103
+ div(dataComputed(Signal[Double]("total")) := js"$price * $quantity").renderMinified
104
+ // res4: String = "<div data-computed:total=\"$price * $quantity\"></div>"
105
+ ```
106
+
107
+ The signal name becomes the attribute key, kebab-cased.
108
+
109
+ ## Displaying and Binding
110
+
111
+ Three helpers read a signal and change what the element shows, without any handler being involved.
112
+
113
+ ### `dataText` and `dataShow`
114
+
115
+ `dataText` sets an element's text content from an expression, and `dataShow` controls its visibility:
116
+
117
+ ```scala
118
+ import zio.blocks.html._
119
+ import zio.http.datastar._
120
+
121
+ val count = Signal[Int]("count")
122
+ ```
123
+
124
+ Both take an expression, so a signal or a `js"..."` both work:
125
+
126
+ ```scala
127
+ span(dataText := count).renderMinified
128
+ // res6: String = "<span data-text=\"$count\"></span>"
129
+ div(dataShow := js"$count > 0", "non-empty").renderMinified
130
+ // res7: String = "<div data-show=\"$count &gt; 0\">non-empty</div>"
131
+ ```
132
+
133
+ ### `dataBind` — two-way input binding
134
+
135
+ `dataBind(signal)` binds a form control to a signal in both directions, and needs no value:
136
+
137
+ ```scala
138
+ input(dataBind(count)).renderMinified
139
+ // res8: String = "<input data-bind:count/>"
140
+ ```
141
+
142
+ The rendered attribute is `data-bind:<name>` with no value, which is how Datastar recognizes the binding form.
143
+
144
+ ## Styling
145
+
146
+ Three helpers follow the same keyed-or-bare pattern: a key selects what to modify, and the expression decides when.
147
+
148
+ | Helper | Keyed form | Purpose |
149
+ | ------ | ---------- | ------- |
150
+ | `dataClass(name)` | `data-class:<name>` | Toggle one class |
151
+ | `dataClass` | `data-class` | Object of class names to conditions |
152
+ | `dataStyle(name)` | `data-style:<name>` | Set one CSS property |
153
+ | `dataStyle` | `data-style` | Object of properties to values |
154
+ | `dataAttr(name)` | `data-attr:<name>` | Set one HTML attribute |
155
+ | `dataAttr` | `data-attr` | Object of attributes to values |
156
+
157
+ The keyed form is the common one, toggling a single class from a condition:
158
+
159
+ ```scala
160
+ import zio.blocks.html._
161
+ import zio.http.datastar._
162
+
163
+ val selected = Signal[Boolean]("selected")
164
+ ```
165
+
166
+ Keyed and bare forms render differently, and the bare form takes an object expression:
167
+
168
+ ```scala
169
+ div(dataClass("active") := selected).renderMinified
170
+ // res10: String = "<div data-class:active=\"$selected\"></div>"
171
+ div(dataStyle("color") := js"$selected ? 'red' : 'gray'").renderMinified
172
+ // res11: String = "<div data-style:color=\"$selected ? &#x27;red&#x27; : &#x27;gray&#x27;\"></div>"
173
+ ```
174
+
175
+ ## Effects and References
176
+
177
+ `dataEffect` runs an expression whenever its dependencies change, `dataIndicator(signal)` sets a signal while a request is in flight, and `dataRef(name)` exposes the element to expressions by name:
178
+
179
+ ```scala
180
+ import zio.blocks.html._
181
+ import zio.http.datastar._
182
+
183
+ val loading = Signal[Boolean]("loading")
184
+ ```
185
+
186
+ `dataIndicator` and `dataRef` are complete attributes rather than keys, since their argument is the whole content:
187
+
188
+ ```scala
189
+ button(dataIndicator(loading), "save").renderMinified
190
+ // res13: String = "<button data-indicator:loading>save</button>"
191
+ div(dataRef("panel")).renderMinified
192
+ // res14: String = "<div data-ref:panel></div>"
193
+ ```
194
+
195
+ ## Morph Control
196
+
197
+ When the server patches elements, Datastar morphs the existing DOM rather than replacing it. Four helpers control what that morph may touch — all of them complete attributes taking no expression:
198
+
199
+ | Helper | Effect |
200
+ | ------ | ------ |
201
+ | `dataIgnore` | Datastar ignores this element and its subtree entirely |
202
+ | `dataIgnoreSelf` | Ignores this element but still processes its children |
203
+ | `dataIgnoreMorph` | Preserves this element across morphs |
204
+ | `dataPreserveAttr(attrs*)` | Preserves the named attributes across morphs |
205
+
206
+ Preserving an attribute matters for state the server does not know about — a scroll position, an open `details`, a user-resized width:
207
+
208
+ ```scala
209
+ import zio.blocks.html._
210
+ import zio.http.datastar._
211
+ ```
212
+
213
+ The first three are boolean attributes; the fourth names what to keep:
214
+
215
+ ```scala
216
+ div(dataIgnore).renderMinified
217
+ // res16: String = "<div data-ignore></div>"
218
+ div(dataPreserveAttr("open", "style")).renderMinified
219
+ // res17: String = "<div data-preserve-attr=\"open style\"></div>"
220
+ ```
221
+
222
+ ## Other Attributes
223
+
224
+ `dataJsonSignals` renders the full signal state as JSON, which is useful for debugging a page's reactive state, and `dataOnSignalPatchFilter` narrows which signal patches a `data-on-signal-patch` handler responds to. Both are `DatastarAttrKey`s taking an expression.
225
+
226
+ The trigger helpers — `dataOn`, `dataInit`, `dataOnIntersect`, `dataOnInterval`, `dataOnSignalPatch` — are covered in [Event Handlers](./events.md).
227
+
228
+ ## ToDatastarExpr
229
+
230
+ `ToDatastarExpr[A]` is the type class deciding what may be assigned with `:=`. It is contravariant, and instances derive from `ToJs[A]`, so anything the HTML module can render as JavaScript works here.
231
+
232
+ ### What Is Accepted
233
+
234
+ An instance exists for any `A` with a `ToJs[A]` — which covers `Js` values from the `js"..."` interpolator, `Signal[A]`, `SignalUpdate[A]`, and `DatastarRef`:
235
+
236
+ ```scala
237
+ import zio.blocks.html._
238
+ import zio.http.datastar._
239
+
240
+ val count = Signal[Int]("count")
241
+ ```
242
+
243
+ Each renders to its expression form:
244
+
245
+ ```scala
246
+ span(dataText := count).renderMinified
247
+ // res19: String = "<span data-text=\"$count\"></span>"
248
+ span(dataText := js"$count + 1").renderMinified
249
+ // res20: String = "<span data-text=\"$count + 1\"></span>"
250
+ ```
251
+
252
+ ### Why `String` Is Rejected
253
+
254
+ There is deliberately **no** usable instance for `String`. The mechanism is a private `NotString` witness with two conflicting instances for `String`, which makes the implicit search ambiguous, plus an `@implicitAmbiguous` annotation supplying the message:
255
+
256
+ ```scala
257
+ @implicitAmbiguous(
258
+ "Raw String values are not allowed in Datastar expression positions. " +
259
+ "Use js\"...\" for Datastar expressions or typed Signal/SignalUpdate values."
260
+ )
261
+ private sealed trait NotString[A]
262
+ ```
263
+
264
+ The result is that `dataText := "count"` does not compile, and the error tells you to use `js"..."` instead.
265
+
266
+ :::tip[This is the module's most valuable guard]
267
+ Assigning a raw `String` is the one Datastar mistake that produces valid HTML and no error at runtime — the attribute renders, the browser reads it as a literal, and the element silently shows nothing useful. Making it a compile error is worth the unusual implicit machinery.
268
+ :::
269
+
270
+ `@implicitNotFound` covers the other case: a type with no `ToJs` instance at all gets a message naming the type and pointing at the same two options.
271
+
272
+ ## DatastarAttrKey
273
+
274
+ `DatastarAttrKey` is the low-level escape hatch — a raw attribute name plus `:=`. Every keyed helper returns one, and you can construct one for an attribute the module has no helper for yet.
275
+
276
+ Since the constructor is `private[datastar]`, reach it through a helper that returns a bare key. `dataAttr`, `dataClass`, `dataStyle`, `dataText`, `dataShow`, `dataEffect`, `dataSignals`, `dataJsonSignals`, and `dataOnSignalPatchFilter` all have bare forms:
277
+
278
+ ```scala
279
+ import zio.blocks.html._
280
+ import zio.http.datastar._
281
+
282
+ val theme = Signal[String]("theme")
283
+ ```
284
+
285
+ The bare form takes an object expression covering several keys at once:
286
+
287
+ ```scala
288
+ div(dataStyle := js"{color: $theme}").renderMinified
289
+ // res22: String = "<div data-style=\"{color: $theme}\"></div>"
290
+ ```
291
+
292
+ :::warning[`dataAttr` collides with the HTML module]
293
+ `zio.blocks.html` also defines `dataAttr`, for plain static `data-*` attributes — `dataAttr("id") := "42"` renders `data-id="42"`. Datastar's renders `data-attr:id="<expression>"`, a reactive binding. With both packages wildcard-imported, the bare name is ambiguous and will not compile.
294
+
295
+ Qualify the one you mean, `zio.http.datastar.dataAttr` for the reactive form, or import selectively. The ambiguity error is the good case: it stops you silently getting a static attribute where you wanted a binding.
296
+ :::
297
+
298
+ ## Integration Points
299
+
300
+ Every helper returns a `Dom.Attribute` from [HTML](../html.md), so Datastar attributes are indistinguishable from `id` or `class` at the point of use and compose in the same element constructors. `ToDatastarExpr` derives from that module's `ToJs`, and the `js"..."` interpolator producing most expression values is also its.
301
+
302
+ Values assigned here are usually [Signals](./signals.md), and the triggers that pair with these attributes are in [Event Handlers](./events.md). What the server sends back to change them is in [Server-Sent Events](./sse.md).