lix 0.15.1

Embeddable version control for apps and AI agents.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
use std::fmt::Write as _;

use serde_json::{Value as JsonValue, json};

/// Structured error type surfaced by Lix to every SDK binding.
///
/// Carries a machine-readable [`code`](Self::code), a human-readable
/// [`message`](Self::message), and an optional [`hint`](Self::hint)
/// suggesting how to recover. Hints follow the Postgres/rustc convention:
/// `message` states what went wrong in factual terms, and `hint` offers a
/// possible fix when one is known.
///
/// ```
/// use lix::LixError;
///
/// let err = LixError::new(
///     "LIX_ERROR_UNSUPPORTED_WRITE_EXPRESSION",
///     "json(...) is not supported",
/// )
/// .with_hint("cast the value with ::jsonb instead");
///
/// assert_eq!(err.hint(), Some("cast the value with ::jsonb instead"));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LixError {
    pub code: String,
    pub message: String,
    pub hint: Option<String>,
    pub details: Option<JsonValue>,
}

impl LixError {
    /// True fallback — use when no more specific category fits. Producing
    /// sites should prefer the categorized codes below whenever possible;
    /// the SDK contract is that `LIX_ERROR_UNKNOWN` is the *last* resort,
    /// never the default.
    pub const CODE_UNKNOWN: &'static str = "LIX_ERROR_UNKNOWN";

    /// SQL text could not be parsed.
    pub const CODE_PARSE_ERROR: &'static str = "LIX_PARSE_ERROR";

    /// A SQL function name could not be resolved.
    pub const CODE_UDF_NOT_FOUND: &'static str = "LIX_UDF_NOT_FOUND";

    /// A SQL expression or function argument had an incompatible type.
    pub const CODE_TYPE_MISMATCH: &'static str = "LIX_TYPE_MISMATCH";

    /// A Lix JSON path argument used another dialect's path language instead
    /// of Lix's canonical variadic key/index segments.
    pub const CODE_INVALID_JSON_PATH: &'static str = "LIX_INVALID_JSON_PATH";

    /// SQL syntax belongs to another dialect and is outside the Lix SQL
    /// surface.
    pub const CODE_DIALECT_UNSUPPORTED: &'static str = "LIX_DIALECT_UNSUPPORTED";

    /// SQL parameters could not be bound to placeholders.
    pub const CODE_BINDING_ERROR: &'static str = "LIX_BINDING_ERROR";

    /// A caller supplied an invalid SQL parameter value or parameter list.
    pub const CODE_INVALID_PARAM: &'static str = "LIX_INVALID_PARAM";

    /// A SQL table or view name could not be resolved.
    pub const CODE_TABLE_NOT_FOUND: &'static str = "LIX_TABLE_NOT_FOUND";

    /// A SQL column name could not be resolved in the available projection.
    pub const CODE_COLUMN_NOT_FOUND: &'static str = "LIX_COLUMN_NOT_FOUND";

    /// A SQL write violated a primary-key, unique, NOT NULL, or other
    /// relational constraint.
    pub const CODE_CONSTRAINT_VIOLATION: &'static str = "LIX_CONSTRAINT_VIOLATION";

    /// A SQL write targeted a read-only internal/component surface.
    pub const CODE_READ_ONLY: &'static str = "LIX_ERROR_READ_ONLY";

    /// SQL syntax is valid, but the feature is intentionally outside the Lix
    /// SQL surface.
    pub const CODE_UNSUPPORTED_SQL: &'static str = "LIX_UNSUPPORTED_SQL";

    /// SQL planning succeeded far enough to produce a physical runtime shape
    /// that the current engine target cannot execute safely.
    pub const CODE_UNSUPPORTED_SQL_RUNTIME_PLAN: &'static str = "LIX_UNSUPPORTED_SQL_RUNTIME_PLAN";

    /// Storage I/O failed.
    pub const CODE_STORAGE_ERROR: &'static str = "LIX_STORAGE_ERROR";

    /// A coherent storage read was invalidated by a concurrent commit.
    /// Auto-commit read surfaces consume this internally by reopening the
    /// complete read/query against a fresh snapshot.
    pub const CODE_STORAGE_READ_EXPIRED: &'static str = "LIX_STORAGE_READ_EXPIRED";

    /// The selected storage cannot prove the requested persistence boundary.
    pub const CODE_STORAGE_DURABILITY_UNAVAILABLE: &'static str =
        "LIX_STORAGE_DURABILITY_UNAVAILABLE";

    /// A snapshot is malformed, unsupported, truncated, or fails integrity
    /// verification.
    pub const CODE_INVALID_SNAPSHOT: &'static str = "LIX_INVALID_SNAPSHOT";

    /// Reading or writing a snapshot stream failed.
    pub const CODE_SNAPSHOT_IO: &'static str = "LIX_SNAPSHOT_IO";

    /// A newer storage client fenced this writer, so this Lix instance can no
    /// longer serve requests.
    pub const CODE_STORAGE_FENCED: &'static str = "LIX_STORAGE_FENCED";

    /// The backing storage instance stopped and this Lix instance can no
    /// longer serve requests.
    pub const CODE_STORAGE_CLOSED: &'static str = "LIX_STORAGE_CLOSED";

    /// A storage commit may have been applied, but its caller did not receive
    /// a definitive result.
    pub const CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN: &'static str =
        "LIX_STORAGE_COMMIT_OUTCOME_UNKNOWN";

    /// A server SQL mutation did not provide the required replay identity.
    pub const CODE_IDEMPOTENCY_KEY_REQUIRED: &'static str = "LIX_IDEMPOTENCY_KEY_REQUIRED";

    /// A replay identity was reused for a different logical mutation.
    pub const CODE_IDEMPOTENCY_KEY_REUSED: &'static str = "LIX_IDEMPOTENCY_KEY_REUSED";

    /// A mutation response cannot be retained safely for idempotent replay.
    pub const CODE_IDEMPOTENCY_RESPONSE_TOO_LARGE: &'static str =
        "LIX_IDEMPOTENCY_RESPONSE_TOO_LARGE";

    /// Optimistic transaction publication lost a race with a newer commit.
    pub const CODE_TRANSACTION_CONFLICT: &'static str = "LIX_TRANSACTION_CONFLICT";

    /// An internal engine invariant failed.
    pub const CODE_INTERNAL_ERROR: &'static str = "LIX_INTERNAL_ERROR";

    /// A plugin ZIP package or manifest is malformed, unsafe, or exceeds the
    /// static resource bounds accepted by the engine. Invalid embedded Lix
    /// schema definitions retain [`Self::CODE_SCHEMA_DEFINITION`].
    pub const CODE_INVALID_PLUGIN: &'static str = "LIX_ERROR_INVALID_PLUGIN";

    /// A file is materialized as durable plugin state, but the plugin needed
    /// to render that state is not installed on the file's branch.
    pub const CODE_PLUGIN_UNAVAILABLE: &'static str = "LIX_ERROR_PLUGIN_UNAVAILABLE";

    /// An incremental plugin write did not carry an exact, still-current
    /// private document observation. The client must re-read the file; the
    /// engine never guesses identity authority from equal byte hashes.
    pub const CODE_PLUGIN_OBSERVATION_STALE: &'static str = "LIX_ERROR_PLUGIN_OBSERVATION_STALE";

    /// Creating another live plugin Store would exceed the repository-wide
    /// runtime admission limit configured for this Engine.
    pub const CODE_PLUGIN_RESOURCE_LIMIT: &'static str = "LIX_ERROR_PLUGIN_RESOURCE_LIMIT";

    /// Write-time failure where user data did not conform to a registered
    /// schema (type mismatch, missing required field, pattern violation,
    /// additionalProperties, etc.). Raised from the JSON-Schema validator
    /// run over a candidate row's snapshot.
    pub const CODE_SCHEMA_VALIDATION: &'static str = "LIX_ERROR_SCHEMA_VALIDATION";

    /// A foreign-key constraint could not be satisfied. Covers both the
    /// insert-side "no matching target row" failure and the delete-side
    /// "still referenced" (restrict) failure.
    pub const CODE_FOREIGN_KEY: &'static str = "LIX_ERROR_FOREIGN_KEY";

    /// A row references a non-null `file_id` that has no matching `lix_file`
    /// descriptor in the same effective branch scope.
    pub const CODE_FILE_NOT_FOUND: &'static str = "LIX_ERROR_FILE_NOT_FOUND";

    /// A primary-key or `x-lix-unique` constraint was violated — another
    /// row already owns the value(s) for the declared pointer group.
    pub const CODE_UNIQUE: &'static str = "LIX_ERROR_UNIQUE";

    /// An `INSERT ... VALUES (...)` expression is not supported by the
    /// public write surface (e.g. `json(...)`, subqueries, arbitrary SQL
    /// expressions). Users should cast inline JSON with `::jsonb`.
    pub const CODE_UNSUPPORTED_WRITE_EXPRESSION: &'static str =
        "LIX_ERROR_UNSUPPORTED_WRITE_EXPRESSION";

    /// The schema JSON itself (the *definition*, not a row against it) is
    /// malformed — a missing `key`, an invalid primary-key column, or another
    /// leading slash, a reserved-namespace collision, or any other
    /// meta-schema validation failure.
    pub const CODE_SCHEMA_DEFINITION: &'static str = "LIX_ERROR_SCHEMA_DEFINITION";

    /// A public runtime schema registration attempted to use the `lix_*`
    /// namespace reserved for schemas owned and bootstrapped by Lix.
    pub const CODE_RESERVED_SCHEMA_NAMESPACE: &'static str = "LIX_RESERVED_SCHEMA_NAMESPACE";

    /// The logical Lix handle/session has been closed and cannot run further
    /// operations. Close is a resource-release lifecycle boundary, not a
    /// durability boundary.
    pub const CODE_CLOSED: &'static str = "LIX_ERROR_CLOSED";

    /// An operation is incompatible with the current session mode or state.
    pub const CODE_INVALID_SESSION_STATE: &'static str = "LIX_INVALID_SESSION_STATE";

    /// A merge found incompatible changes to the same tracked-state identity.
    pub const CODE_MERGE_CONFLICT: &'static str = "LIX_MERGE_CONFLICT";

    /// A caller referenced a branch id that has no matching branch ref.
    pub const CODE_BRANCH_NOT_FOUND: &'static str = "LIX_BRANCH_NOT_FOUND";

    /// A caller referenced a commit id that has no matching commit record.
    pub const CODE_COMMIT_NOT_FOUND: &'static str = "LIX_COMMIT_NOT_FOUND";

    /// A staged row's storage scope flags disagree, such as a global row not
    /// using the reserved global branch id.
    pub const CODE_INVALID_STORAGE_SCOPE: &'static str = "LIX_ERROR_INVALID_STORAGE_SCOPE";

    /// Merge graph analysis found multiple equally valid merge bases.
    pub const CODE_AMBIGUOUS_MERGE_BASE: &'static str = "LIX_AMBIGUOUS_MERGE_BASE";

    /// A merge request is well-formed but nonsensical for the commit graph,
    /// such as merging a branch into itself.
    pub const CODE_INVALID_MERGE: &'static str = "LIX_INVALID_MERGE";

    /// The selected branch has no ordinary commit above its undo boundary.
    pub const CODE_NOTHING_TO_UNDO: &'static str = "LIX_NOTHING_TO_UNDO";

    /// The selected branch has no abandoned action available to replay.
    pub const CODE_NOTHING_TO_REDO: &'static str = "LIX_NOTHING_TO_REDO";

    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            message: message.into(),
            hint: None,
            details: None,
        }
    }

    pub fn unknown(message: impl Into<String>) -> Self {
        Self::new("LIX_ERROR_UNKNOWN", message)
    }

    pub fn branch_not_found(
        branch_id: impl Into<String>,
        operation: impl Into<String>,
        role: impl Into<String>,
    ) -> Self {
        let branch_id = branch_id.into();
        let operation = operation.into();
        let role = role.into();
        Self::new(
            Self::CODE_BRANCH_NOT_FOUND,
            format!("branch '{branch_id}' was not found"),
        )
        .with_details(json!({
            "branch_id": branch_id,
            "operation": operation,
            "role": role,
        }))
    }

    pub fn commit_not_found(
        commit_id: impl Into<String>,
        operation: impl Into<String>,
        role: impl Into<String>,
    ) -> Self {
        let commit_id = commit_id.into();
        let operation = operation.into();
        let role = role.into();
        Self::new(
            Self::CODE_COMMIT_NOT_FOUND,
            format!("commit '{commit_id}' was not found"),
        )
        .with_details(json!({
            "commit_id": commit_id,
            "operation": operation,
            "role": role,
        }))
    }

    /// A row could not be normalized because its schema is outside the
    /// transaction's visible commit graph and durability scope.
    pub fn schema_not_visible(
        schema_key: impl Into<String>,
        entity_commit_id: Option<impl Into<String>>,
        base_commit_id: Option<impl Into<String>>,
        branch_id: impl Into<String>,
        untracked: bool,
    ) -> Self {
        let schema_key = schema_key.into();
        let entity_commit_id = entity_commit_id.map(Into::into);
        let base_commit_id = base_commit_id.map(Into::into);
        let branch_id = branch_id.into();
        let scope = format!("branch:{branch_id}");
        let hint = match (entity_commit_id.as_deref(), base_commit_id.as_deref()) {
            (Some(entity_commit_id), Some(base_commit_id)) if entity_commit_id != base_commit_id => {
                format!(
                    "The entity comes from commit {entity_commit_id}, but schema '{schema_key}' is not visible from this transaction's base commit {base_commit_id} in {scope} ({} lane). This usually indicates that the entity commit is not an ancestor of the transaction base, or that the schema was registered in a different branch or durability scope.",
                    if untracked { "untracked" } else { "tracked" }
                )
            }
            (_, Some(base_commit_id)) => format!(
                "Schema '{schema_key}' is not visible from this transaction's base commit {base_commit_id} in {scope} ({} lane). This usually indicates that the schema was registered in a different branch or durability scope.",
                if untracked { "untracked" } else { "tracked" }
            ),
            _ => format!(
                "Schema '{schema_key}' is not visible in {scope} ({} lane). This usually indicates that the schema was registered in a different branch or durability scope.",
                if untracked { "untracked" } else { "tracked" }
            ),
        };
        let mut details = serde_json::Map::from_iter([
            ("schema_key".to_string(), JsonValue::String(schema_key.clone())),
            ("scope".to_string(), JsonValue::String(scope)),
            (
                "durability".to_string(),
                JsonValue::String(if untracked { "untracked" } else { "tracked" }.to_string()),
            ),
        ]);
        if let Some(entity_commit_id) = entity_commit_id {
            details.insert(
                "entity_commit_id".to_string(),
                JsonValue::String(entity_commit_id),
            );
        }
        if let Some(base_commit_id) = base_commit_id {
            details.insert(
                "base_commit_id".to_string(),
                JsonValue::String(base_commit_id),
            );
        }
        Self::new(
            Self::CODE_SCHEMA_DEFINITION,
            format!("schema '{schema_key}' is not visible to this transaction"),
        )
        .with_details(JsonValue::Object(details))
        .with_hint(hint)
    }

    /// Construct an internal invariant failure with the entity coordinates
    /// needed to turn the surfaced error into an actionable bug report.
    pub fn internal_invariant(message: impl Into<String>, entities: JsonValue) -> Self {
        Self::new(Self::CODE_INTERNAL_ERROR, message).with_details(entities)
    }

    pub fn ambiguous_merge_base(
        left_commit_id: impl Into<String>,
        right_commit_id: impl Into<String>,
        candidates: Vec<String>,
    ) -> Self {
        let left_commit_id = left_commit_id.into();
        let right_commit_id = right_commit_id.into();
        Self::new(
            Self::CODE_AMBIGUOUS_MERGE_BASE,
            format!("ambiguous merge base between '{left_commit_id}' and '{right_commit_id}'"),
        )
        .with_details(json!({
            "left_commit_id": left_commit_id,
            "right_commit_id": right_commit_id,
            "candidates": candidates,
        }))
    }

    pub fn invalid_self_merge(branch_id: impl Into<String>) -> Self {
        let branch_id = branch_id.into();
        Self::new(
            Self::CODE_INVALID_MERGE,
            format!("cannot merge branch '{branch_id}' into itself"),
        )
        .with_details(json!({
            "operation": "merge_branch",
            "target_branch_id": branch_id,
            "source_branch_id": branch_id,
        }))
    }

    /// Attach a hint to this error. Consumers render hints alongside the
    /// primary message (e.g. a CLI prints them as `hint: <text>`).
    ///
    /// ```
    /// use lix::LixError;
    ///
    /// let err = LixError::new("CODE", "boom").with_hint("try this");
    /// assert_eq!(err.hint(), Some("try this"));
    /// ```
    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(hint.into());
        self
    }

    /// Attach machine-readable details to this error.
    pub fn with_details(mut self, details: JsonValue) -> Self {
        self.details = Some(details);
        self
    }

    /// Return the attached hint, if any.
    ///
    /// Returns `None` when no hint was attached at the error's producer
    /// site. This is the accessor SDK consumers should prefer over
    /// reading the `hint` field directly — it returns `Option<&str>`,
    /// avoiding the need for `.as_deref()` at the call site.
    ///
    /// ```
    /// use lix::LixError;
    ///
    /// let without_hint = LixError::new("CODE", "boom");
    /// assert_eq!(without_hint.hint(), None);
    ///
    /// let with_hint = LixError::new("CODE", "boom").with_hint("fix it");
    /// assert_eq!(with_hint.hint(), Some("fix it"));
    /// ```
    pub fn hint(&self) -> Option<&str> {
        self.hint.as_deref()
    }

    pub fn format(&self) -> String {
        let mut s = format!("code: {}\nmessage: {}", self.code, self.message);
        if let Some(hint) = &self.hint {
            let _ = write!(s, "\nhint: {hint}");
        }
        s
    }
}

impl From<crate::storage_adapter::StorageError> for LixError {
    fn from(error: crate::storage_adapter::StorageError) -> Self {
        match error {
            crate::storage_adapter::StorageError::WriteConflict
            | crate::storage_adapter::StorageError::PreconditionFailed(_) => Self::new(
                Self::CODE_TRANSACTION_CONFLICT,
                "transaction snapshot is stale because tracked state changed before commit",
            )
            .with_hint("Retry the transaction against the latest committed state."),
            crate::storage_adapter::StorageError::Fenced => Self::new(
                Self::CODE_STORAGE_FENCED,
                "the storage writer was fenced by a newer client",
            )
            .with_hint(
                "Do not automatically retry this request; a mutation may still have completed.",
            )
            .with_details(json!({
                "retryable": false,
                "outcome": "unknown",
            })),
            crate::storage_adapter::StorageError::Closed(_) => Self::new(
                Self::CODE_STORAGE_CLOSED,
                "the storage instance closed and must be reopened",
            )
            .with_hint(
                "Do not automatically retry this request; a mutation may still have completed.",
            )
            .with_details(json!({
                "retryable": false,
                "outcome": "unknown",
            })),
            crate::storage_adapter::StorageError::CommitOutcomeUnknown(message) => Self::new(
                Self::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN,
                format!("the storage commit outcome is unknown: {message}"),
            )
            .with_hint(
                "Do not automatically retry this request; a mutation may still have completed.",
            )
            .with_details(json!({
                "retryable": false,
                "outcome": "unknown",
            })),
            crate::storage_adapter::StorageError::Durability => Self::new(
                Self::CODE_STORAGE_DURABILITY_UNAVAILABLE,
                "the storage backend cannot prove the requested durability boundary",
            ),
            crate::storage_adapter::StorageError::ReadExpired => Self::new(
                Self::CODE_STORAGE_READ_EXPIRED,
                "the coherent storage read was invalidated by a concurrent commit",
            )
            .with_details(json!({
                "retryable": true,
            })),
            error => Self::new(Self::CODE_STORAGE_ERROR, error.to_string()),
        }
    }
}

impl From<crate::storage_adapter::StorageWriteSetError> for LixError {
    fn from(error: crate::storage_adapter::StorageWriteSetError) -> Self {
        match error {
            crate::storage_adapter::StorageWriteSetError::Storage(error) => error.into(),
            error => Self::new(Self::CODE_STORAGE_ERROR, error.to_string()),
        }
    }
}

impl std::fmt::Display for LixError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.format())
    }
}

impl std::error::Error for LixError {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn format_without_hint_omits_hint_line() {
        let err = LixError::new("LIX_ERROR_FOO", "something went wrong");
        assert_eq!(
            err.format(),
            "code: LIX_ERROR_FOO\nmessage: something went wrong"
        );
        assert!(err.hint.is_none());
    }

    #[test]
    fn format_with_hint_appends_hint_line() {
        let err = LixError::new("LIX_ERROR_FOO", "something went wrong").with_hint("try the fix");
        assert_eq!(
            err.format(),
            "code: LIX_ERROR_FOO\nmessage: something went wrong\nhint: try the fix"
        );
    }

    #[test]
    fn with_hint_is_chainable_and_replaces_prior_hint() {
        let err = LixError::new("LIX_ERROR_FOO", "desc")
            .with_hint("first")
            .with_hint("second");
        assert_eq!(err.hint.as_deref(), Some("second"));
    }

    #[test]
    fn new_defaults_hint_to_none() {
        let err = LixError::new("CODE", "desc");
        assert_eq!(err.hint, None);
    }

    #[test]
    fn unknown_defaults_hint_to_none() {
        let err = LixError::unknown("desc");
        assert_eq!(err.code, "LIX_ERROR_UNKNOWN");
        assert_eq!(err.hint, None);
    }

    #[test]
    fn fenced_storage_error_is_terminal_and_not_retryable() {
        let error = LixError::from(crate::storage::StorageError::Fenced);

        assert_eq!(error.code, LixError::CODE_STORAGE_FENCED);
        assert_eq!(
            error.details,
            Some(serde_json::json!({
                "retryable": false,
                "outcome": "unknown",
            }))
        );
    }

    #[test]
    fn unknown_commit_outcome_is_not_retryable() {
        let error = LixError::from(crate::storage::StorageError::CommitOutcomeUnknown(
            "storage reply was lost after commit".to_string(),
        ));

        assert_eq!(error.code, LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN);
        assert_eq!(
            error.details,
            Some(serde_json::json!({
                "retryable": false,
                "outcome": "unknown",
            }))
        );
        assert_eq!(
            error.hint(),
            Some("Do not automatically retry this request; a mutation may still have completed.")
        );
    }

    #[test]
    fn fenced_storage_write_set_error_preserves_the_terminal_code() {
        let error = LixError::from(crate::storage_adapter::StorageWriteSetError::Storage(
            crate::storage::StorageError::Fenced,
        ));

        assert_eq!(error.code, LixError::CODE_STORAGE_FENCED);
    }

    #[test]
    fn closed_storage_error_is_terminal_and_not_retryable() {
        let error = LixError::from(crate::storage::StorageError::Closed(
            "background worker panicked".to_string(),
        ));

        assert_eq!(error.code, LixError::CODE_STORAGE_CLOSED);
        assert_eq!(
            error.details,
            Some(serde_json::json!({
                "retryable": false,
                "outcome": "unknown",
            }))
        );
    }
}