Skip to main content

hayson_binding_shared/
json.rs

1use hayson_engine::catalog::{IntegrityReport, MigrateOutcome, OrphanRecord, Relation};
2use hayson_engine::compaction::CompactionResult;
3use hayson_engine::database::{
4    BackupResult, Layout, LayoutMigrationResult, PackResult, RestoreResult, Severity, VerifyReport,
5};
6use hayson_engine::query::{AccessPathSummary, OverlayStrategy, PlanBranch, PlanOverlay, QueryStrategy};
7use hayson_engine::schema::HaysonRecord;
8use hayson_engine::table::{BrokenLink, ImageInfo, ImageUsage, RefreshOutcome, RotationResult, TableStats};
9use hayson_engine::txn::UnitOfWorkResult;
10use serde_json::{json, Value};
11
12pub fn migrate_format1_outcome_to_json(outcome: &MigrateOutcome) -> Value {
13    match outcome {
14        MigrateOutcome::AlreadyMigrated { catalog } => json!({
15            "migrated": false,
16            "catalog": catalog.display().to_string(),
17            "tables": [],
18            "removedSchemaFiles": [],
19        }),
20        MigrateOutcome::Migrated {
21            catalog,
22            tables,
23            removed_schema_files,
24        } => json!({
25            "migrated": true,
26            "catalog": catalog.display().to_string(),
27            "tables": tables,
28            "removedSchemaFiles": removed_schema_files
29                .iter()
30                .map(|path| path.display().to_string())
31                .collect::<Vec<_>>(),
32        }),
33    }
34}
35
36pub fn record_to_json(record: &HaysonRecord) -> Value {
37    Value::Object(
38        record
39            .iter()
40            .map(|(field, value)| (field.clone(), value.clone()))
41            .collect(),
42    )
43}
44
45/// The broken markdown links of a record, as the JS side reads them: one object per link, the
46/// field it sits in against the virtual path it aimed at. Structured rather than a formatted
47/// sentence, so a caller can group by field or retry a target without parsing text apart.
48pub fn broken_links_to_json(broken: &[BrokenLink]) -> Value {
49    Value::Array(
50        broken
51            .iter()
52            .map(|link| json!({ "field": link.field, "path": link.path }))
53            .collect(),
54    )
55}
56
57/// `[{ physicalPath, bytes, missing, referencedBy: [{ key, field, virtualPath }] }]` — what
58/// `listImages()` answers with. An empty `referencedBy` is an orphan.
59pub fn images_to_json(images: &[ImageInfo]) -> Value {
60    Value::Array(
61        images
62            .iter()
63            .map(|image| {
64                json!({
65                    "physicalPath": image.physical_path,
66                    "bytes": image.bytes,
67                    "missing": image.is_missing(),
68                    "referencedBy": image
69                        .referenced_by
70                        .iter()
71                        .map(|r| json!({ "key": r.key, "field": r.field, "virtualPath": r.virtual_path }))
72                        .collect::<Vec<_>>(),
73                })
74            })
75            .collect(),
76    )
77}
78
79/// `{ count, bytes, orphanCount, orphanBytes }` — what `imageUsage()` answers with.
80pub fn image_usage_to_json(usage: &ImageUsage) -> Value {
81    json!({
82        "count": usage.count,
83        "bytes": usage.bytes,
84        "orphanCount": usage.orphan_count,
85        "orphanBytes": usage.orphan_bytes,
86    })
87}
88
89/// `{ dest, files, bytes }` — what `backup()` answers with.
90pub fn backup_result_to_json(result: &BackupResult) -> Value {
91    json!({ "dest": result.dest.display().to_string(), "files": result.files, "bytes": result.bytes })
92}
93
94/// `{ dest, files, bytes, archiveBytes }` — what `pack()` answers with.
95pub fn pack_result_to_json(result: &PackResult) -> Value {
96    json!({
97        "dest": result.dest.display().to_string(),
98        "files": result.files,
99        "bytes": result.bytes,
100        "archiveBytes": result.archive_bytes,
101    })
102}
103
104/// `{ dir, files, bytes, replaced }` — what `restore()` answers with.
105pub fn restore_result_to_json(result: &RestoreResult) -> Value {
106    json!({
107        "dir": result.dir.display().to_string(),
108        "files": result.files,
109        "bytes": result.bytes,
110        "replaced": result.replaced,
111    })
112}
113
114fn strategy_name(strategy: QueryStrategy) -> &'static str {
115    match strategy {
116        QueryStrategy::Primary => "primary",
117        QueryStrategy::Exact => "exact",
118        QueryStrategy::Sorted => "sorted",
119        QueryStrategy::Fulltext => "fulltext",
120        QueryStrategy::Union => "union",
121        QueryStrategy::Scan => "scan",
122    }
123}
124
125fn plan_branch_to_json(branch: &PlanBranch) -> Value {
126    json!({
127        "strategy": strategy_name(branch.strategy),
128        "field": branch.field,
129        "candidates": branch.candidates,
130    })
131}
132
133fn overlay_strategy_name(strategy: OverlayStrategy) -> &'static str {
134    match strategy {
135        OverlayStrategy::Exact => "exact",
136        OverlayStrategy::Fulltext => "fulltext",
137        OverlayStrategy::Scan => "scan",
138    }
139}
140
141fn plan_overlay_to_json(overlay: &PlanOverlay) -> Value {
142    json!({
143        "staged": overlay.staged,
144        "suppressed": overlay.suppressed,
145        "rescored": overlay.rescored,
146        "strategy": overlay_strategy_name(overlay.strategy),
147        "size": overlay.size,
148    })
149}
150
151pub fn plan_to_json(plan: &AccessPathSummary) -> Value {
152    json!({
153        "strategy": strategy_name(plan.strategy),
154        "field": plan.field,
155        "index": plan.index,
156        "candidates": plan.candidates,
157        "ranked": plan.ranked,
158        "filteredOnProjection": plan.filtered_on_projection,
159        "reads": plan.reads,
160        "branches": plan.branches.as_ref().map(|branches| branches.iter().map(plan_branch_to_json).collect::<Vec<_>>()),
161        "overlay": plan.overlay.as_ref().map(plan_overlay_to_json),
162    })
163}
164
165/// The whole answer to `Table.query`: the keys of the page, in query order, the records
166/// themselves, how many records matched before `offset`/`limit` cut that page out of them, and the
167/// plan that found them — one document rather than a bare array, so the shim can rebuild the
168/// `QueryResult` the TS package already exposes without a second round trip for the plan.
169pub fn query_result_to_json(
170    keys: &[String],
171    records: &[HaysonRecord],
172    matched: usize,
173    plan: &AccessPathSummary,
174) -> Value {
175    json!({
176        "keys": keys,
177        "records": records.iter().map(record_to_json).collect::<Vec<_>>(),
178        "matched": matched,
179        "plan": plan_to_json(plan),
180    })
181}
182
183/// The answer to a `getMany`: one entry per key the caller asked for, in that order, `null` where
184/// the table holds no record — so the caller can zip it against its own key list without a second
185/// lookup for which key went missing.
186pub fn optional_records_to_json(records: &[Option<HaysonRecord>]) -> Value {
187    Value::Array(
188        records
189            .iter()
190            .map(|record| record.as_ref().map(record_to_json).unwrap_or(Value::Null))
191            .collect(),
192    )
193}
194
195/// The whole answer to committing a unit of work, in the shape the TS `UnitOfWorkResult` declares.
196/// `tables` is what the unit actually touched, which includes tables reached only by a trigger
197/// effect and not named by any staged operation.
198pub fn unit_of_work_result_to_json(result: &UnitOfWorkResult) -> Value {
199    json!({ "id": result.id, "tables": result.tables, "operations": result.operations })
200}
201
202pub fn relation_to_json(relation: &Relation) -> Value {
203    json!({
204        "name": relation.name,
205        "from": { "table": relation.from.table, "field": relation.from.field },
206        "to": { "table": relation.to.table, "field": relation.to.field },
207        "cardinality": relation.cardinality.as_str(),
208        "onDelete": relation.on_delete.map(|action| action.as_str()),
209        "enforce": relation.enforce,
210    })
211}
212
213pub fn relations_to_json(relations: &[Relation]) -> Value {
214    Value::Array(relations.iter().map(relation_to_json).collect())
215}
216
217fn orphan_record_to_json(orphan: &OrphanRecord) -> Value {
218    json!({
219        "relation": orphan.relation,
220        "table": orphan.table,
221        "field": orphan.field,
222        "primaryKey": orphan.primary_key,
223        "value": orphan.value,
224    })
225}
226
227pub fn integrity_report_to_json(report: &IntegrityReport) -> Value {
228    json!({ "orphans": report.orphans.iter().map(orphan_record_to_json).collect::<Vec<_>>() })
229}
230
231/// The counters of `Table.stats()`, in the member names the TS `TableStats` already uses.
232/// Where a database keeps its files, in the shape of the TS `Database.layout` property — the
233/// deprecated aliases (`catalogFile`/`catalog`/`manifestFile`/`manifest`) carried alongside the
234/// current names, holding the same values, because the TS shim reports them all.
235pub fn layout_to_json(layout: &Layout) -> Value {
236    let metadata_file = layout.metadata_file();
237    let metadata_path = layout.metadata_path().display().to_string();
238    json!({
239        "kind": layout.kind().as_str(),
240        "root": layout.root().display().to_string(),
241        "metadataFile": metadata_file,
242        "metadataPath": metadata_path,
243        "catalogFile": metadata_file,
244        "catalog": metadata_path,
245        "manifestFile": metadata_file,
246        "manifest": metadata_path,
247        "tables": layout.tables().display().to_string(),
248        "indexes": layout.indexes().display().to_string(),
249        "runtime": layout.runtime().display().to_string(),
250        "lock": layout.lock_path().display().to_string(),
251        "journal": layout.journal_path().display().to_string(),
252    })
253}
254
255/// What `migrateLayout()` answers with, in the shape of the TS `LayoutMigrationResult`. Every path
256/// crosses as the engine spelled it, which on the WASM binding is a path inside the module's
257/// preopened namespace — the shim rewrites those into host paths, as it already does for a refusal.
258pub fn layout_migration_to_json(result: &LayoutMigrationResult) -> Value {
259    json!({
260        "root": result.root.display().to_string(),
261        "from": result.from.as_str(),
262        "to": result.to.as_str(),
263        "moves": result
264            .moves
265            .iter()
266            .map(|step| json!({ "from": step.from.display().to_string(), "to": step.to.display().to_string() }))
267            .collect::<Vec<_>>(),
268        "unclaimed": result
269            .unclaimed
270            .iter()
271            .map(|path| path.display().to_string())
272            .collect::<Vec<_>>(),
273        "changed": result.changed,
274        "dryRun": result.dry_run,
275    })
276}
277
278/// What `verify()` answers with, in the shape of the TS `VerifyReport`. Paths in `file` are
279/// relative to the database directory, so they mean the same on every binding.
280pub fn verify_report_to_json(report: &VerifyReport) -> Value {
281    json!({
282        "problems": report
283            .problems
284            .iter()
285            .map(|problem| json!({
286                "severity": match problem.severity {
287                    Severity::Error => "error",
288                    Severity::Warning => "warning",
289                },
290                "code": problem.code,
291                "table": problem.table,
292                "file": problem.file,
293                "offset": problem.offset,
294                "message": problem.message,
295            }))
296            .collect::<Vec<_>>(),
297        "tablesChecked": report.tables_checked,
298        "logLinesChecked": report.log_lines_checked,
299    })
300}
301
302pub fn table_stats_to_json(stats: &TableStats) -> Value {
303    json!({
304        "records": stats.records,
305        "liveBytes": stats.live_bytes,
306        "deadBytes": stats.dead_bytes,
307        "logBytes": stats.log_bytes,
308        "queueDepth": stats.queue_depth,
309        "gen": stats.gen,
310        "seq": stats.seq,
311    })
312}
313
314/// What a `Table.refresh()` found. `changed` is the boolean the TS `Table.refresh()` answers with;
315/// `outcome` and `entries` say how it got there, which is what tells a caller apart the cheap
316/// catch-up from the rebuild a foreign compaction forces.
317pub fn refresh_outcome_to_json(outcome: RefreshOutcome) -> Value {
318    let (name, entries) = match outcome {
319        RefreshOutcome::Unchanged => ("unchanged", 0),
320        RefreshOutcome::CaughtUp { entries } => ("caughtUp", entries),
321        RefreshOutcome::Reloaded => ("reloaded", 0),
322    };
323    json!({ "changed": outcome.changed(), "outcome": name, "entries": entries })
324}
325
326/// What a `Table.rotateCypherKeys()` did — records, not fields, and the three outcomes apart, so a
327/// caller can tell "nothing to do" from "a record was left behind for want of its key".
328pub fn rotation_result_to_json(result: &RotationResult) -> Value {
329    json!({
330        "rotated": result.rotated,
331        "alreadyCurrent": result.already_current,
332        "skipped": result.skipped,
333    })
334}
335
336// NOTE: `slots` is dropped on purpose — an internal remap detail, not a caller-facing stat.
337pub fn compaction_result_to_json(result: &CompactionResult) -> Value {
338    json!({
339        "records": result.records,
340        "bytesBefore": result.bytes_before,
341        "bytesAfter": result.bytes_after,
342        "reclaimedBytes": result.reclaimed_bytes,
343    })
344}
345
346#[cfg(test)]
347mod tests {
348    use hayson_engine::catalog::{RelationCardinality, RelationEnd};
349
350    use super::*;
351
352    #[test]
353    fn a_query_result_carries_keys_records_the_match_count_and_the_plan_in_one_document() {
354        let mut record = HaysonRecord::new();
355        record.insert("id".into(), json!("u1"));
356        record.insert("name".into(), json!("Ania"));
357        let plan = AccessPathSummary {
358            strategy: QueryStrategy::Exact,
359            field: Some("email".into()),
360            index: Some("users_email".into()),
361            candidates: 1,
362            ranked: false,
363            filtered_on_projection: true,
364            overlay: None,
365            reads: 1,
366            branches: None,
367        };
368
369        let described = query_result_to_json(&["u1".to_string()], &[record], 7, &plan);
370
371        assert_eq!(described["keys"], json!(["u1"]));
372        assert_eq!(described["records"], json!([{ "id": "u1", "name": "Ania" }]));
373        assert_eq!(
374            described["matched"],
375            json!(7),
376            "the page is one key wide, the match count is not"
377        );
378        assert_eq!(described["plan"]["strategy"], json!("exact"));
379        assert_eq!(described["plan"]["index"], json!("users_email"));
380        assert_eq!(described["plan"]["branches"], Value::Null);
381    }
382
383    #[test]
384    fn a_plan_carries_ranked_and_filtered_on_projection_under_the_names_the_ts_query_plan_declares() {
385        let plan = AccessPathSummary {
386            strategy: QueryStrategy::Fulltext,
387            field: None,
388            index: None,
389            candidates: 2,
390            ranked: true,
391            filtered_on_projection: false,
392            overlay: None,
393            reads: 2,
394            branches: None,
395        };
396
397        let described = plan_to_json(&plan);
398
399        assert_eq!(described["ranked"], json!(true));
400        assert_eq!(described["filteredOnProjection"], json!(false));
401    }
402
403    #[test]
404    fn every_query_strategy_has_a_lower_case_name_the_ts_union_already_uses() {
405        let all = [
406            QueryStrategy::Primary,
407            QueryStrategy::Exact,
408            QueryStrategy::Sorted,
409            QueryStrategy::Fulltext,
410            QueryStrategy::Union,
411            QueryStrategy::Scan,
412        ];
413        let names: Vec<&str> = all.into_iter().map(strategy_name).collect();
414
415        assert_eq!(names, vec!["primary", "exact", "sorted", "fulltext", "union", "scan"]);
416    }
417
418    #[test]
419    fn a_relation_is_flattened_into_the_shape_the_ts_relation_type_declares() {
420        let relation = Relation {
421            name: "orders_user".into(),
422            from: RelationEnd {
423                table: "orders".into(),
424                field: "userId".into(),
425            },
426            to: RelationEnd {
427                table: "users".into(),
428                field: "id".into(),
429            },
430            cardinality: RelationCardinality::ManyToOne,
431            on_delete: None,
432            enforce: Some(true),
433        };
434
435        let described = relations_to_json(&[relation]);
436
437        assert_eq!(described[0]["from"], json!({ "table": "orders", "field": "userId" }));
438        assert_eq!(described[0]["onDelete"], Value::Null);
439        assert_eq!(described[0]["enforce"], json!(true));
440    }
441
442    #[test]
443    fn a_get_many_answer_keeps_a_hole_where_the_table_held_no_record() {
444        let mut record = HaysonRecord::new();
445        record.insert("id".into(), json!("u1"));
446
447        let described = optional_records_to_json(&[None, Some(record), None]);
448
449        assert_eq!(described, json!([Value::Null, { "id": "u1" }, Value::Null]));
450    }
451
452    #[test]
453    fn a_committed_unit_reports_the_id_the_tables_and_how_many_operations_it_carried() {
454        let result = UnitOfWorkResult {
455            id: "42-1-0".into(),
456            tables: vec!["users".into(), "audit".into()],
457            operations: 3,
458        };
459
460        let described = unit_of_work_result_to_json(&result);
461
462        assert_eq!(
463            described,
464            json!({ "id": "42-1-0", "tables": ["users", "audit"], "operations": 3 })
465        );
466    }
467
468    #[test]
469    fn table_stats_cross_the_boundary_under_the_camel_case_names_the_ts_type_declares() {
470        let stats = TableStats {
471            records: 2,
472            live_bytes: 40,
473            dead_bytes: 10,
474            log_bytes: 50,
475            queue_depth: 0,
476            gen: 1,
477            seq: 7,
478        };
479
480        let described = table_stats_to_json(&stats);
481
482        assert_eq!(
483            described,
484            json!({ "records": 2, "liveBytes": 40, "deadBytes": 10, "logBytes": 50, "queueDepth": 0, "gen": 1, "seq": 7 })
485        );
486    }
487
488    #[test]
489    fn each_refresh_outcome_names_itself_and_agrees_with_its_own_changed_flag() {
490        let unchanged = refresh_outcome_to_json(RefreshOutcome::Unchanged);
491        let caught_up = refresh_outcome_to_json(RefreshOutcome::CaughtUp { entries: 3 });
492        let reloaded = refresh_outcome_to_json(RefreshOutcome::Reloaded);
493
494        assert_eq!(
495            unchanged,
496            json!({ "changed": false, "outcome": "unchanged", "entries": 0 })
497        );
498        assert_eq!(
499            caught_up,
500            json!({ "changed": true, "outcome": "caughtUp", "entries": 3 })
501        );
502        assert_eq!(
503            reloaded,
504            json!({ "changed": true, "outcome": "reloaded", "entries": 0 })
505        );
506    }
507
508    #[test]
509    fn a_record_keeps_its_field_order_across_the_boundary() {
510        let mut record = HaysonRecord::new();
511        record.insert("id".into(), json!("u1"));
512        record.insert("age".into(), json!(31));
513
514        let text = record_to_json(&record).to_string();
515
516        assert_eq!(
517            text, r#"{"age":31,"id":"u1"}"#,
518            "HaysonRecord is a BTreeMap — field order is its key order"
519        );
520    }
521}