1use hayson_engine::catalog::{IntegrityReport, MigrateOutcome, OrphanRecord, Relation};
2use hayson_engine::compaction::CompactionResult;
3use hayson_engine::crypto::PasswordCheck;
4use hayson_engine::database::{
5 BackupResult, 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
46pub 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
58pub 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
80pub 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
90pub fn password_check_to_json(check: &PasswordCheck) -> Value {
92 json!({ "ok": check.ok, "needsRehash": check.needs_rehash })
93}
94
95pub fn backup_result_to_json(result: &BackupResult) -> Value {
97 json!({ "dest": result.dest.display().to_string(), "files": result.files, "bytes": result.bytes })
98}
99
100pub 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 })
108}
109
110pub fn restore_result_to_json(result: &RestoreResult) -> Value {
112 json!({
113 "dir": result.dir.display().to_string(),
114 "files": result.files,
115 "bytes": result.bytes,
116 "replaced": result.replaced,
117 })
118}
119
120fn strategy_name(strategy: QueryStrategy) -> &'static str {
121 match strategy {
122 QueryStrategy::Primary => "primary",
123 QueryStrategy::Exact => "exact",
124 QueryStrategy::Sorted => "sorted",
125 QueryStrategy::Fulltext => "fulltext",
126 QueryStrategy::Union => "union",
127 QueryStrategy::Scan => "scan",
128 }
129}
130
131fn plan_branch_to_json(branch: &PlanBranch) -> Value {
132 json!({
133 "strategy": strategy_name(branch.strategy),
134 "field": branch.field,
135 "candidates": branch.candidates,
136 })
137}
138
139fn overlay_strategy_name(strategy: OverlayStrategy) -> &'static str {
140 match strategy {
141 OverlayStrategy::Exact => "exact",
142 OverlayStrategy::Fulltext => "fulltext",
143 OverlayStrategy::Scan => "scan",
144 }
145}
146
147fn plan_overlay_to_json(overlay: &PlanOverlay) -> Value {
148 json!({
149 "staged": overlay.staged,
150 "suppressed": overlay.suppressed,
151 "rescored": overlay.rescored,
152 "strategy": overlay_strategy_name(overlay.strategy),
153 "size": overlay.size,
154 })
155}
156
157pub fn plan_to_json(plan: &AccessPathSummary) -> Value {
158 json!({
159 "strategy": strategy_name(plan.strategy),
160 "field": plan.field,
161 "index": plan.index,
162 "candidates": plan.candidates,
163 "ranked": plan.ranked,
164 "filteredOnProjection": plan.filtered_on_projection,
165 "reads": plan.reads,
166 "branches": plan.branches.as_ref().map(|branches| branches.iter().map(plan_branch_to_json).collect::<Vec<_>>()),
167 "overlay": plan.overlay.as_ref().map(plan_overlay_to_json),
168 })
169}
170
171pub fn query_result_to_json(
176 keys: &[String],
177 records: &[HaysonRecord],
178 matched: usize,
179 plan: &AccessPathSummary,
180) -> Value {
181 json!({
182 "keys": keys,
183 "records": records.iter().map(record_to_json).collect::<Vec<_>>(),
184 "matched": matched,
185 "plan": plan_to_json(plan),
186 })
187}
188
189pub fn optional_records_to_json(records: &[Option<HaysonRecord>]) -> Value {
193 Value::Array(
194 records
195 .iter()
196 .map(|record| record.as_ref().map(record_to_json).unwrap_or(Value::Null))
197 .collect(),
198 )
199}
200
201pub fn unit_of_work_result_to_json(result: &UnitOfWorkResult) -> Value {
205 json!({ "id": result.id, "tables": result.tables, "operations": result.operations })
206}
207
208pub fn relation_to_json(relation: &Relation) -> Value {
209 json!({
210 "name": relation.name,
211 "from": { "table": relation.from.table, "field": relation.from.field },
212 "to": { "table": relation.to.table, "field": relation.to.field },
213 "cardinality": relation.cardinality.as_str(),
214 "onDelete": relation.on_delete.map(|action| action.as_str()),
215 "enforce": relation.enforce,
216 })
217}
218
219pub fn relations_to_json(relations: &[Relation]) -> Value {
220 Value::Array(relations.iter().map(relation_to_json).collect())
221}
222
223fn orphan_record_to_json(orphan: &OrphanRecord) -> Value {
224 json!({
225 "relation": orphan.relation,
226 "table": orphan.table,
227 "field": orphan.field,
228 "primaryKey": orphan.primary_key,
229 "value": orphan.value,
230 })
231}
232
233pub fn integrity_report_to_json(report: &IntegrityReport) -> Value {
234 json!({ "orphans": report.orphans.iter().map(orphan_record_to_json).collect::<Vec<_>>() })
235}
236
237pub fn layout_to_json(layout: &Layout) -> Value {
242 let metadata_file = layout.metadata_file();
243 let metadata_path = layout.metadata_path().display().to_string();
244 json!({
245 "kind": layout.kind().as_str(),
246 "root": layout.root().display().to_string(),
247 "metadataFile": metadata_file,
248 "metadataPath": metadata_path,
249 "catalogFile": metadata_file,
250 "catalog": metadata_path,
251 "manifestFile": metadata_file,
252 "manifest": metadata_path,
253 "tables": layout.tables().display().to_string(),
254 "indexes": layout.indexes().display().to_string(),
255 "runtime": layout.runtime().display().to_string(),
256 "lock": layout.lock_path().display().to_string(),
257 "journal": layout.journal_path().display().to_string(),
258 })
259}
260
261pub fn layout_migration_to_json(result: &LayoutMigrationResult) -> Value {
265 json!({
266 "root": result.root.display().to_string(),
267 "from": result.from.as_str(),
268 "to": result.to.as_str(),
269 "moves": result
270 .moves
271 .iter()
272 .map(|step| json!({ "from": step.from.display().to_string(), "to": step.to.display().to_string() }))
273 .collect::<Vec<_>>(),
274 "unclaimed": result
275 .unclaimed
276 .iter()
277 .map(|path| path.display().to_string())
278 .collect::<Vec<_>>(),
279 "changed": result.changed,
280 "dryRun": result.dry_run,
281 })
282}
283
284pub fn verify_report_to_json(report: &VerifyReport) -> Value {
287 json!({
288 "problems": report
289 .problems
290 .iter()
291 .map(|problem| json!({
292 "severity": match problem.severity {
293 Severity::Error => "error",
294 Severity::Warning => "warning",
295 },
296 "code": problem.code,
297 "table": problem.table,
298 "file": problem.file,
299 "offset": problem.offset,
300 "message": problem.message,
301 }))
302 .collect::<Vec<_>>(),
303 "tablesChecked": report.tables_checked,
304 "logLinesChecked": report.log_lines_checked,
305 })
306}
307
308pub fn table_stats_to_json(stats: &TableStats) -> Value {
309 json!({
310 "records": stats.records,
311 "liveBytes": stats.live_bytes,
312 "deadBytes": stats.dead_bytes,
313 "logBytes": stats.log_bytes,
314 "queueDepth": stats.queue_depth,
315 "gen": stats.gen,
316 "seq": stats.seq,
317 })
318}
319
320pub fn refresh_outcome_to_json(outcome: RefreshOutcome) -> Value {
324 let (name, entries) = match outcome {
325 RefreshOutcome::Unchanged => ("unchanged", 0),
326 RefreshOutcome::CaughtUp { entries } => ("caughtUp", entries),
327 RefreshOutcome::Reloaded => ("reloaded", 0),
328 };
329 json!({ "changed": outcome.changed(), "outcome": name, "entries": entries })
330}
331
332pub fn rotation_result_to_json(result: &RotationResult) -> Value {
335 json!({
336 "rotated": result.rotated,
337 "alreadyCurrent": result.already_current,
338 "skipped": result.skipped,
339 })
340}
341
342pub fn compaction_result_to_json(result: &CompactionResult) -> Value {
344 json!({
345 "records": result.records,
346 "bytesBefore": result.bytes_before,
347 "bytesAfter": result.bytes_after,
348 "reclaimedBytes": result.reclaimed_bytes,
349 })
350}
351
352#[cfg(test)]
353mod tests {
354 use hayson_engine::catalog::{RelationCardinality, RelationEnd};
355
356 use super::*;
357
358 #[test]
359 fn a_query_result_carries_keys_records_the_match_count_and_the_plan_in_one_document() {
360 let mut record = HaysonRecord::new();
361 record.insert("id".into(), json!("u1"));
362 record.insert("name".into(), json!("Ania"));
363 let plan = AccessPathSummary {
364 strategy: QueryStrategy::Exact,
365 field: Some("email".into()),
366 index: Some("users_email".into()),
367 candidates: 1,
368 ranked: false,
369 filtered_on_projection: true,
370 overlay: None,
371 reads: 1,
372 branches: None,
373 };
374
375 let described = query_result_to_json(&["u1".to_string()], &[record], 7, &plan);
376
377 assert_eq!(described["keys"], json!(["u1"]));
378 assert_eq!(described["records"], json!([{ "id": "u1", "name": "Ania" }]));
379 assert_eq!(
380 described["matched"],
381 json!(7),
382 "the page is one key wide, the match count is not"
383 );
384 assert_eq!(described["plan"]["strategy"], json!("exact"));
385 assert_eq!(described["plan"]["index"], json!("users_email"));
386 assert_eq!(described["plan"]["branches"], Value::Null);
387 }
388
389 #[test]
390 fn a_plan_carries_ranked_and_filtered_on_projection_under_the_names_the_ts_query_plan_declares() {
391 let plan = AccessPathSummary {
392 strategy: QueryStrategy::Fulltext,
393 field: None,
394 index: None,
395 candidates: 2,
396 ranked: true,
397 filtered_on_projection: false,
398 overlay: None,
399 reads: 2,
400 branches: None,
401 };
402
403 let described = plan_to_json(&plan);
404
405 assert_eq!(described["ranked"], json!(true));
406 assert_eq!(described["filteredOnProjection"], json!(false));
407 }
408
409 #[test]
410 fn every_query_strategy_has_a_lower_case_name_the_ts_union_already_uses() {
411 let all = [
412 QueryStrategy::Primary,
413 QueryStrategy::Exact,
414 QueryStrategy::Sorted,
415 QueryStrategy::Fulltext,
416 QueryStrategy::Union,
417 QueryStrategy::Scan,
418 ];
419 let names: Vec<&str> = all.into_iter().map(strategy_name).collect();
420
421 assert_eq!(names, vec!["primary", "exact", "sorted", "fulltext", "union", "scan"]);
422 }
423
424 #[test]
425 fn a_relation_is_flattened_into_the_shape_the_ts_relation_type_declares() {
426 let relation = Relation {
427 name: "orders_user".into(),
428 from: RelationEnd {
429 table: "orders".into(),
430 field: "userId".into(),
431 },
432 to: RelationEnd {
433 table: "users".into(),
434 field: "id".into(),
435 },
436 cardinality: RelationCardinality::ManyToOne,
437 on_delete: None,
438 enforce: Some(true),
439 };
440
441 let described = relations_to_json(&[relation]);
442
443 assert_eq!(described[0]["from"], json!({ "table": "orders", "field": "userId" }));
444 assert_eq!(described[0]["onDelete"], Value::Null);
445 assert_eq!(described[0]["enforce"], json!(true));
446 }
447
448 #[test]
449 fn a_get_many_answer_keeps_a_hole_where_the_table_held_no_record() {
450 let mut record = HaysonRecord::new();
451 record.insert("id".into(), json!("u1"));
452
453 let described = optional_records_to_json(&[None, Some(record), None]);
454
455 assert_eq!(described, json!([Value::Null, { "id": "u1" }, Value::Null]));
456 }
457
458 #[test]
459 fn a_committed_unit_reports_the_id_the_tables_and_how_many_operations_it_carried() {
460 let result = UnitOfWorkResult {
461 id: "42-1-0".into(),
462 tables: vec!["users".into(), "audit".into()],
463 operations: 3,
464 };
465
466 let described = unit_of_work_result_to_json(&result);
467
468 assert_eq!(
469 described,
470 json!({ "id": "42-1-0", "tables": ["users", "audit"], "operations": 3 })
471 );
472 }
473
474 #[test]
475 fn table_stats_cross_the_boundary_under_the_camel_case_names_the_ts_type_declares() {
476 let stats = TableStats {
477 records: 2,
478 live_bytes: 40,
479 dead_bytes: 10,
480 log_bytes: 50,
481 queue_depth: 0,
482 gen: 1,
483 seq: 7,
484 };
485
486 let described = table_stats_to_json(&stats);
487
488 assert_eq!(
489 described,
490 json!({ "records": 2, "liveBytes": 40, "deadBytes": 10, "logBytes": 50, "queueDepth": 0, "gen": 1, "seq": 7 })
491 );
492 }
493
494 #[test]
495 fn each_refresh_outcome_names_itself_and_agrees_with_its_own_changed_flag() {
496 let unchanged = refresh_outcome_to_json(RefreshOutcome::Unchanged);
497 let caught_up = refresh_outcome_to_json(RefreshOutcome::CaughtUp { entries: 3 });
498 let reloaded = refresh_outcome_to_json(RefreshOutcome::Reloaded);
499
500 assert_eq!(
501 unchanged,
502 json!({ "changed": false, "outcome": "unchanged", "entries": 0 })
503 );
504 assert_eq!(
505 caught_up,
506 json!({ "changed": true, "outcome": "caughtUp", "entries": 3 })
507 );
508 assert_eq!(
509 reloaded,
510 json!({ "changed": true, "outcome": "reloaded", "entries": 0 })
511 );
512 }
513
514 #[test]
515 fn a_record_keeps_its_field_order_across_the_boundary() {
516 let mut record = HaysonRecord::new();
517 record.insert("id".into(), json!("u1"));
518 record.insert("age".into(), json!(31));
519
520 let text = record_to_json(&record).to_string();
521
522 assert_eq!(
523 text, r#"{"age":31,"id":"u1"}"#,
524 "HaysonRecord is a BTreeMap — field order is its key order"
525 );
526 }
527}