Skip to main content

hayson_binding_shared/
errors.rs

1use std::io;
2use std::path::Path;
3
4use hayson_engine::atomic::AtomicTemporaryFileError;
5use hayson_engine::database::DatabaseError;
6use hayson_engine::errors::{HaysonCipherError, HaysonIndexMismatchError};
7use hayson_engine::lock::{DirLockError, LockedError};
8use hayson_engine::table::TableError;
9use hayson_engine::txn::TxnError;
10use serde_json::{json, Map, Value};
11
12/// Envelope version of the JSON carried in every thrown `Error.message`. Bumped only if the shape
13/// below changes incompatibly — the TS shim reads it before trusting `code`.
14pub const ENVELOPE_VERSION: u64 = 1;
15
16/// Stable, machine-readable tags a TS shim switches on to re-throw the matching `HaysonXxxError`.
17/// Every `DatabaseError`/`TableError` variant maps onto exactly one of these.
18pub mod code {
19    pub const VALIDATION: &str = "Validation";
20    pub const UNIQUE_VIOLATION: &str = "UniqueViolation";
21    pub const PENDING_CONFLICT: &str = "PendingConflict";
22    pub const NOT_FOUND: &str = "NotFound";
23    pub const SCHEMA: &str = "Schema";
24    pub const PARTITION: &str = "Partition";
25    pub const CIPHER: &str = "Cipher";
26    pub const LOCKED: &str = "Locked";
27    pub const LOCK_LOST: &str = "LockLost";
28    pub const SUSPICIOUS_LOCK_FILE: &str = "SuspiciousLockFile";
29    pub const READ_ONLY: &str = "ReadOnly";
30    pub const FIELD_IN_USE: &str = "FieldInUse";
31    pub const CATALOG: &str = "Catalog";
32    pub const TXN: &str = "Txn";
33    pub const INDEX_MISMATCH: &str = "IndexMismatch";
34    pub const TEMPORARY_FILE: &str = "TemporaryFile";
35    pub const IO: &str = "Io";
36    pub const INVALID_JSON: &str = "InvalidJson";
37    pub const CLOSED: &str = "Closed";
38}
39
40fn envelope(code: &str, message: String, fields: Map<String, Value>) -> Value {
41    let mut object = Map::new();
42    object.insert("envelopeVersion".to_string(), json!(ENVELOPE_VERSION));
43    object.insert("code".to_string(), json!(code));
44    object.insert("message".to_string(), json!(message));
45    for (key, value) in fields {
46        object.insert(key, value);
47    }
48    Value::Object(object)
49}
50
51fn no_fields() -> Map<String, Value> {
52    Map::new()
53}
54
55fn fields(pairs: Value) -> Map<String, Value> {
56    match pairs {
57        Value::Object(map) => map,
58        _ => Map::new(),
59    }
60}
61
62fn describe_cipher_error(error: &HaysonCipherError) -> Value {
63    envelope(
64        code::CIPHER,
65        error.to_string(),
66        fields(json!({
67            "table": error.table,
68            "field": error.field,
69            "reason": error.reason,
70            "keyId": error.key_id,
71        })),
72    )
73}
74
75fn describe_locked_error(error: &LockedError) -> Value {
76    // NOTE: `subject` is the database directory, not the lock file — `DirLock::acquire` takes the
77    // parent of the path it was given before building this error.
78    let dir = error.subject.display().to_string();
79    let holder = error.holder.as_ref().map(|holder| {
80        json!({
81            "owner": holder.owner,
82            "pid": holder.pid,
83            "host": holder.host,
84            "startedAt": holder.started_at,
85            "heartbeatAt": holder.heartbeat_at,
86        })
87    });
88    envelope(
89        code::LOCKED,
90        error.to_string(),
91        fields(json!({ "dir": dir, "holder": holder, "waitedMs": error.waited_ms })),
92    )
93}
94
95/// `table` is the name the call was made against — `TableError` itself carries one only on the
96/// variants built from a `HaysonXxxError`, and even then not on `Io`.
97pub fn describe_table_error(table: &str, error: &TableError) -> Value {
98    let message = error.to_string();
99    match error {
100        TableError::Validation(inner) => envelope(
101            code::VALIDATION,
102            message,
103            fields(json!({
104                "table": inner.table,
105                "issues": inner
106                    .issues
107                    .iter()
108                    .map(|issue| json!({ "field": issue.field, "reason": issue.reason }))
109                    .collect::<Vec<_>>(),
110            })),
111        ),
112        TableError::Unique(inner) => envelope(
113            code::UNIQUE_VIOLATION,
114            message,
115            fields(json!({ "table": inner.table, "field": inner.field, "holder": inner.holder })),
116        ),
117        TableError::Conflict(inner) => envelope(
118            code::PENDING_CONFLICT,
119            message,
120            fields(json!({ "table": inner.table, "key": inner.key, "fields": inner.fields })),
121        ),
122        TableError::NotFound(inner) => envelope(
123            code::NOT_FOUND,
124            message,
125            fields(json!({ "table": inner.table, "key": inner.key })),
126        ),
127        TableError::Schema(_) => envelope(code::SCHEMA, message, fields(json!({ "table": table }))),
128        TableError::Partition(inner) => envelope(
129            code::PARTITION,
130            message,
131            fields(json!({ "table": inner.table, "field": inner.field, "reason": inner.reason })),
132        ),
133        TableError::Cipher(inner) => describe_cipher_error(inner),
134        TableError::Io(inner) => describe_io_error(inner),
135    }
136}
137
138/// `checkpoint::restore_or_rebuild` reports a snapshot paired with a different log through
139/// `io::Error::other`, so the typed refusal survives only as the boxed source of that io error.
140/// Without this the shim sees `Io` and cannot tell the index-mismatch refusal from a disk failure.
141fn index_mismatch_of(error: &io::Error) -> Option<&HaysonIndexMismatchError> {
142    error.get_ref()?.downcast_ref::<HaysonIndexMismatchError>()
143}
144
145/// `atomic::open_temporary` refuses a `.tmp` path it could not claim through an `io::Error` that
146/// boxes the typed refusal, so the path and the reason survive only as that boxed source. Without
147/// this the shim sees `Io` — or, on a catalog write, `Catalog` — and the caller loses the one file
148/// it has to remove before retrying.
149fn temporary_file_of(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
150    AtomicTemporaryFileError::downcast(error)
151}
152
153pub fn describe_io_error(error: &io::Error) -> Value {
154    if let Some(inner) = temporary_file_of(error) {
155        return envelope(
156            code::TEMPORARY_FILE,
157            error.to_string(),
158            fields(json!({
159                "path": inner.path.display().to_string(),
160                "reason": inner.reason,
161            })),
162        );
163    }
164    if let Some(inner) = index_mismatch_of(error) {
165        return envelope(
166            code::INDEX_MISMATCH,
167            error.to_string(),
168            fields(json!({
169                "indexPath": inner.path,
170                "logPath": inner.log_path,
171                "watermark": inner.watermark,
172                "expectedHash": inner.expected_hash,
173                "actualHash": inner.actual_hash,
174            })),
175        );
176    }
177    envelope(
178        code::IO,
179        error.to_string(),
180        fields(json!({ "kind": format!("{:?}", error.kind()) })),
181    )
182}
183
184/// The errno name a host raised in a language whose IO errors carry one — Node reports it as
185/// `error.code`, and a caller that means to tell "this file is gone" from "this file is unreadable"
186/// has nothing else to go on. `None` for a kind with no single portable name, in which case the
187/// envelope simply omits it rather than guessing.
188fn errno_name_of(error: &io::Error) -> Option<&'static str> {
189    match error.kind() {
190        io::ErrorKind::NotFound => Some("ENOENT"),
191        io::ErrorKind::PermissionDenied => Some("EACCES"),
192        _ => None,
193    }
194}
195
196/// A [`describe_io_error`] that knows which file failed. `io::Error` carries no path, so only a
197/// call site that kept one can produce this — today `DatabaseError::Log`, raised by the readonly
198/// open probing the logs it is about to take on.
199fn describe_log_io_error(path: &Path, error: &io::Error) -> Value {
200    let mut described = fields(json!({
201        "kind": format!("{:?}", error.kind()),
202        "path": path.display().to_string(),
203    }));
204    if let Some(name) = errno_name_of(error) {
205        described.insert("errnoCode".to_string(), json!(name));
206    }
207    envelope(code::IO, format!("{error}, open \"{}\"", path.display()), described)
208}
209
210/// Public because a binding raises `TxnError::AlreadyCommitted` itself: a unit of work handed to
211/// the engine is consumed there, so a second call on the same host-side handle never reaches the
212/// engine to be refused by it.
213pub fn describe_txn_error(error: &TxnError) -> Value {
214    match error {
215        TxnError::Table(name, inner) => describe_table_error(name, inner),
216        TxnError::Io(inner) => describe_io_error(inner),
217        TxnError::UnknownTable(name) => envelope(code::NOT_FOUND, error.to_string(), fields(json!({ "table": name }))),
218        TxnError::AlreadyCommitted | TxnError::JournalPending(_) => envelope(code::TXN, error.to_string(), no_fields()),
219    }
220}
221
222pub fn describe_database_error(error: &DatabaseError) -> Value {
223    let message = error.to_string();
224    match error {
225        DatabaseError::Lock(DirLockError::Locked(inner)) => describe_locked_error(inner),
226        DatabaseError::Lock(DirLockError::Lost(path)) => envelope(
227            code::LOCK_LOST,
228            message,
229            fields(json!({ "lockPath": path.display().to_string() })),
230        ),
231        DatabaseError::Lock(DirLockError::SuspiciousLockFile(path)) => envelope(
232            code::SUSPICIOUS_LOCK_FILE,
233            message,
234            fields(json!({ "lockPath": path.display().to_string() })),
235        ),
236        DatabaseError::Lock(DirLockError::Io(inner)) => describe_io_error(inner),
237        DatabaseError::Schema(_) => envelope(code::SCHEMA, message, no_fields()),
238        DatabaseError::ReadOnly { dir, operation } => envelope(
239            code::READ_ONLY,
240            message,
241            fields(json!({ "dir": dir.display().to_string(), "operation": operation })),
242        ),
243        DatabaseError::FieldInUse { table, field, records } => envelope(
244            code::FIELD_IN_USE,
245            message,
246            fields(json!({ "table": table, "field": field, "records": records })),
247        ),
248        DatabaseError::Catalog(_) => envelope(code::CATALOG, message, no_fields()),
249        DatabaseError::Io(inner) => describe_io_error(inner),
250        DatabaseError::Log(path, inner) => describe_log_io_error(path, inner),
251        DatabaseError::Table(name, inner) => describe_table_error(name, inner),
252        DatabaseError::Txn(inner) => describe_txn_error(inner),
253        DatabaseError::Cipher(inner) => describe_cipher_error(inner),
254    }
255}
256
257pub fn describe_invalid_json(what: &str, error: &serde_json::Error) -> Value {
258    envelope(
259        code::INVALID_JSON,
260        format!("{what} is not valid JSON: {error}"),
261        fields(json!({ "argument": what })),
262    )
263}
264
265pub fn describe_unknown_table(table: &str, dir: &str) -> Value {
266    envelope(
267        code::NOT_FOUND,
268        format!("table \"{table}\" does not exist in \"{dir}\""),
269        fields(json!({ "table": table, "dir": dir })),
270    )
271}
272
273pub fn describe_closed(operation: &str) -> Value {
274    envelope(
275        code::CLOSED,
276        format!("{operation} was called after the database was closed"),
277        fields(json!({ "operation": operation })),
278    )
279}
280
281/// Free-form refusals raised by this binding itself (a malformed `open` option, an unusable key).
282pub fn describe_schema(message: String) -> Value {
283    envelope(code::SCHEMA, message, no_fields())
284}
285
286pub fn describe_cipher_config(message: String) -> Value {
287    envelope(code::CIPHER, message, fields(json!({ "table": "", "field": "" })))
288}
289
290#[cfg(test)]
291mod tests {
292    use hayson_engine::errors::{
293        HaysonNotFoundError, HaysonPartitionError, HaysonSchemaError, HaysonUniqueViolationError,
294        HaysonValidationError, ValidationIssue,
295    };
296    use hayson_engine::lock::LockHolder;
297    use std::path::PathBuf;
298
299    use super::*;
300
301    fn scratch_dir(prefix: &str) -> PathBuf {
302        let dir = std::env::temp_dir().join(format!(
303            "{prefix}{}-{}",
304            std::process::id(),
305            std::time::SystemTime::now()
306                .duration_since(std::time::UNIX_EPOCH)
307                .unwrap()
308                .as_nanos()
309        ));
310        std::fs::create_dir_all(&dir).unwrap();
311        dir
312    }
313
314    fn code_of(value: &Value) -> &str {
315        value.get("code").and_then(Value::as_str).unwrap()
316    }
317
318    #[test]
319    fn a_validation_error_carries_every_issue_the_shim_needs_to_rebuild_it() {
320        let error = TableError::Validation(HaysonValidationError {
321            table: "users".into(),
322            issues: vec![ValidationIssue {
323                field: "name".into(),
324                reason: "is required".into(),
325            }],
326        });
327
328        let described = describe_table_error("users", &error);
329
330        assert_eq!(code_of(&described), code::VALIDATION);
331        assert_eq!(described["table"], json!("users"));
332        assert_eq!(
333            described["issues"],
334            json!([{ "field": "name", "reason": "is required" }])
335        );
336        assert_eq!(described["envelopeVersion"], json!(ENVELOPE_VERSION));
337        assert_eq!(described["message"], json!(error.to_string()));
338    }
339
340    #[test]
341    fn a_unique_violation_names_the_field_and_the_key_already_holding_it() {
342        let error = TableError::Unique(HaysonUniqueViolationError {
343            table: "users".into(),
344            field: "email".into(),
345            holder: "u1".into(),
346        });
347
348        let described = describe_table_error("users", &error);
349
350        assert_eq!(code_of(&described), code::UNIQUE_VIOLATION);
351        assert_eq!(described["field"], json!("email"));
352        assert_eq!(described["holder"], json!("u1"));
353    }
354
355    #[test]
356    fn a_not_found_error_keeps_the_table_and_key_apart_from_the_message() {
357        let error = TableError::NotFound(HaysonNotFoundError {
358            table: "users".into(),
359            key: "u9".into(),
360        });
361
362        let described = describe_table_error("users", &error);
363
364        assert_eq!(code_of(&described), code::NOT_FOUND);
365        assert_eq!(described["key"], json!("u9"));
366    }
367
368    #[test]
369    fn a_partition_error_is_tagged_apart_from_a_plain_schema_rejection() {
370        let partition = TableError::Partition(HaysonPartitionError::new("events", "day", "is empty"));
371        let schema = TableError::Schema(HaysonSchemaError("no".into()));
372
373        assert_eq!(code_of(&describe_table_error("events", &partition)), code::PARTITION);
374        assert_eq!(code_of(&describe_table_error("events", &schema)), code::SCHEMA);
375    }
376
377    #[test]
378    fn a_cipher_error_carries_the_key_id_it_could_not_use() {
379        let error = TableError::Cipher(Box::new(
380            HaysonCipherError::new("secrets", "token", "cannot be opened").with_key_id("k2"),
381        ));
382
383        let described = describe_table_error("secrets", &error);
384
385        assert_eq!(code_of(&described), code::CIPHER);
386        assert_eq!(described["keyId"], json!("k2"));
387        assert_eq!(described["field"], json!("token"));
388    }
389
390    #[test]
391    fn a_locked_database_reports_the_directory_the_holder_and_how_long_we_waited() {
392        let error = DatabaseError::Lock(DirLockError::Locked(LockedError {
393            // `DirLock::acquire` stores the directory here, not the `.hayson.lock` inside it.
394            subject: PathBuf::from("/data/db"),
395            holder: Some(LockHolder {
396                owner: "0f1e2d3c".into(),
397                pid: Some(42),
398                host: Some("box".into()),
399                started_at: 10,
400                heartbeat_at: 20,
401            }),
402            waited_ms: 250,
403        }));
404
405        let described = describe_database_error(&error);
406
407        assert_eq!(code_of(&described), code::LOCKED);
408        assert_eq!(described["dir"], json!("/data/db"));
409        assert_eq!(described["waitedMs"], json!(250));
410        assert_eq!(described["holder"]["owner"], json!("0f1e2d3c"));
411        assert_eq!(described["holder"]["pid"], json!(42));
412        assert_eq!(described["holder"]["startedAt"], json!(10));
413    }
414
415    #[test]
416    fn a_lock_taken_over_by_another_writer_reports_the_lock_path_under_its_own_code() {
417        let error = DatabaseError::Lock(DirLockError::Lost(PathBuf::from("/data/db/.hayson.lock")));
418
419        let described = describe_database_error(&error);
420
421        assert_eq!(code_of(&described), code::LOCK_LOST);
422        assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
423    }
424
425    #[test]
426    fn a_symlink_planted_at_the_lock_path_reports_the_lock_path_under_its_own_code() {
427        let error = DatabaseError::Lock(DirLockError::SuspiciousLockFile(PathBuf::from("/data/db/.hayson.lock")));
428
429        let described = describe_database_error(&error);
430
431        assert_eq!(code_of(&described), code::SUSPICIOUS_LOCK_FILE);
432        assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
433    }
434
435    #[test]
436    fn a_readonly_refusal_is_told_apart_from_a_genuine_schema_error() {
437        let readonly = DatabaseError::ReadOnly {
438            dir: PathBuf::from("/data/db"),
439            operation: "createTable".into(),
440        };
441        let genuine = DatabaseError::Schema(HaysonSchemaError("field \"id\" has no type".into()));
442
443        let described = describe_database_error(&readonly);
444        assert_eq!(code_of(&described), code::READ_ONLY);
445        assert_eq!(described["operation"], json!("createTable"));
446        assert_eq!(described["dir"], json!("/data/db"));
447
448        assert_eq!(code_of(&describe_database_error(&genuine)), code::SCHEMA);
449    }
450
451    #[test]
452    fn a_residual_field_refusal_is_told_apart_from_a_plain_catalog_error() {
453        let in_use = DatabaseError::FieldInUse {
454            table: "users".into(),
455            field: "name".into(),
456            records: 3,
457        };
458        let plain = DatabaseError::Catalog("table \"ghost\" does not exist in \"/data/db\"".into());
459
460        let described = describe_database_error(&in_use);
461        assert_eq!(code_of(&described), code::FIELD_IN_USE);
462        assert_eq!(described["table"], json!("users"));
463        assert_eq!(described["field"], json!("name"));
464        assert_eq!(described["records"], json!(3));
465
466        assert_eq!(code_of(&describe_database_error(&plain)), code::CATALOG);
467    }
468
469    #[test]
470    fn a_table_scoped_database_error_is_described_as_its_inner_table_error() {
471        let error = DatabaseError::Table(
472            "users".into(),
473            TableError::NotFound(HaysonNotFoundError {
474                table: "users".into(),
475                key: "u9".into(),
476            }),
477        );
478
479        assert_eq!(code_of(&describe_database_error(&error)), code::NOT_FOUND);
480    }
481
482    #[test]
483    fn an_unknown_table_inside_a_unit_of_work_is_reported_as_not_found() {
484        let error = DatabaseError::Txn(TxnError::UnknownTable("ghost".into()));
485
486        let described = describe_database_error(&error);
487
488        assert_eq!(code_of(&described), code::NOT_FOUND);
489        assert_eq!(described["table"], json!("ghost"));
490    }
491
492    #[test]
493    fn an_io_failure_keeps_its_kind_so_the_shim_can_tell_missing_from_denied() {
494        let error = DatabaseError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "nope"));
495
496        let described = describe_database_error(&error);
497
498        assert_eq!(code_of(&described), code::IO);
499        assert_eq!(described["kind"], json!("PermissionDenied"));
500    }
501
502    #[test]
503    fn an_io_failure_the_engine_could_name_a_file_for_carries_the_path_and_the_errno_name() {
504        let error = DatabaseError::Log(
505            PathBuf::from("/data/db/notes/note.jsonl"),
506            io::Error::from(io::ErrorKind::NotFound),
507        );
508
509        let described = describe_database_error(&error);
510
511        assert_eq!(code_of(&described), code::IO);
512        assert_eq!(described["path"], json!("/data/db/notes/note.jsonl"));
513        assert_eq!(described["errnoCode"], json!("ENOENT"));
514    }
515
516    #[test]
517    fn an_io_kind_with_no_portable_errno_name_leaves_the_field_out_rather_than_guessing() {
518        let error = DatabaseError::Log(
519            PathBuf::from("/data/db/notes.jsonl"),
520            io::Error::new(io::ErrorKind::InvalidData, "garbled"),
521        );
522
523        let described = describe_database_error(&error);
524
525        assert_eq!(described["path"], json!("/data/db/notes.jsonl"));
526        assert!(described.get("errnoCode").is_none(), "{described}");
527    }
528
529    #[test]
530    fn a_read_that_failed_to_decrypt_arrives_typed_with_the_key_id_the_flattened_text_used_to_lose() {
531        let error = TableError::Cipher(Box::new(
532            HaysonCipherError::new("secrets", "token", "was written with key \"k1\", which is not on hand")
533                .with_key_id("k1"),
534        ));
535
536        let described = describe_table_error("secrets", &error);
537
538        assert_eq!(code_of(&described), code::CIPHER);
539        assert_eq!(described["table"], json!("secrets"));
540        assert_eq!(described["field"], json!("token"));
541        assert_eq!(described["keyId"], json!("k1"));
542    }
543
544    #[test]
545    fn an_io_failure_on_a_read_stays_an_io_failure_now_that_nothing_reads_its_text() {
546        let corrupt = TableError::Io(io::Error::new(
547            io::ErrorKind::InvalidData,
548            "log \"a.jsonl\" is corrupt at offset 12",
549        ));
550
551        assert_eq!(code_of(&describe_table_error("notes", &corrupt)), code::IO);
552    }
553
554    #[test]
555    fn a_search_against_a_table_with_no_fulltext_field_arrives_as_a_typed_schema_refusal() {
556        let error = TableError::Schema(HaysonSchemaError(
557            "table \"users\" has no field marked \"fulltext\": true — search is not available".into(),
558        ));
559
560        let described = describe_table_error("users", &error);
561
562        assert_eq!(code_of(&described), code::SCHEMA);
563        assert_eq!(described["table"], json!("users"));
564    }
565
566    #[test]
567    fn a_refused_index_snapshot_travels_under_its_own_code_with_both_paths_and_the_watermark() {
568        let error = DatabaseError::Io(io::Error::other(HaysonIndexMismatchError {
569            path: "/data/db/users.idx".into(),
570            log_path: "/data/db/users.jsonl".into(),
571            watermark: 397,
572            expected_hash: "9f2c000000000000".into(),
573            actual_hash: "4b1e000000000000".into(),
574        }));
575
576        let described = describe_database_error(&error);
577
578        assert_eq!(code_of(&described), code::INDEX_MISMATCH);
579        assert_eq!(described["indexPath"], json!("/data/db/users.idx"));
580        assert_eq!(described["logPath"], json!("/data/db/users.jsonl"));
581        assert_eq!(described["watermark"], json!(397));
582        assert_eq!(described["expectedHash"], json!("9f2c000000000000"));
583        assert_eq!(described["actualHash"], json!("4b1e000000000000"));
584        assert_eq!(described["message"], json!(error.to_string()));
585    }
586
587    #[test]
588    fn a_temporary_path_that_could_not_be_claimed_travels_under_its_own_code_with_path_and_reason() {
589        let dir = scratch_dir("hayson-envelope-temporary-");
590        // A directory on the .tmp path: open_exclusive reports AlreadyExists and remove_file cannot
591        // clear it, which is the shape of the refusal a catalog write hits on EISDIR.
592        let temporary = dir.join("hayson.json.tmp");
593        std::fs::create_dir(&temporary).unwrap();
594        let refusal = hayson_engine::atomic::open_temporary(&temporary, None).unwrap_err();
595        let message = refusal.to_string();
596        let error = DatabaseError::Io(refusal);
597
598        let described = describe_database_error(&error);
599        std::fs::remove_dir_all(&dir).unwrap();
600
601        assert_eq!(code_of(&described), code::TEMPORARY_FILE);
602        assert_eq!(described["path"], json!(temporary.display().to_string()));
603        assert_eq!(described["reason"], json!("is in the way and could not be cleared, so nothing on disk was changed — remove whatever holds that path and retry"));
604        assert_eq!(described["message"], json!(message));
605    }
606
607    #[test]
608    fn an_io_error_that_boxes_nothing_typed_is_still_reported_as_io() {
609        let error = io::Error::other("snapshot \"a.idx\" claims watermark and schema match the log");
610
611        assert_eq!(code_of(&describe_io_error(&error)), code::IO);
612    }
613}