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