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