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