Skip to main content

hayson_binding_shared/
json.rs

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