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