snowflake-code-unit-registry 0.5.2__tar.gz → 0.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/Cargo.lock +23 -30
  2. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/Cargo.toml +1 -1
  3. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/PKG-INFO +1 -1
  4. snowflake_code_unit_registry-0.5.4/crates/scai-state-core/schemas/history/README.md +14 -0
  5. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/error.rs +23 -1
  6. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/lib.rs +4 -0
  7. snowflake_code_unit_registry-0.5.4/crates/scai-state-core/src/migration/mod.rs +390 -0
  8. snowflake_code_unit_registry-0.5.4/crates/scai-state-core/src/migration/versions/mod.rs +32 -0
  9. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/loader.rs +1 -9
  10. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/paths.rs +14 -1
  11. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/tests.rs +154 -3
  12. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry.rs +65 -2
  13. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/validation.rs +53 -9
  14. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/src/lib.rs +21 -0
  15. snowflake_code_unit_registry-0.5.4/crates/scai-state-python/tests/test_migration.py +95 -0
  16. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_validation.py +23 -1
  17. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/pyproject.toml +1 -1
  18. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/python/snowflake_code_unit_registry/__init__.py +37 -3
  19. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/python/snowflake_code_unit_registry/types.py +1 -1
  20. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/README.md +0 -0
  21. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-error-derive/Cargo.toml +0 -0
  22. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-error-derive/src/lib.rs +0 -0
  23. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-error-derive/tests/derive_integration.rs +0 -0
  24. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/Cargo.toml +0 -0
  25. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/build.rs +0 -0
  26. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/examples/code-unit.example.json +0 -0
  27. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/schemas/code-unit.schema.json +0 -0
  28. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/schemas/query-reference.rs.tmpl +0 -0
  29. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/schemas/query-reference.tmpl +0 -0
  30. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/checksum.rs +0 -0
  31. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/error_trace.rs +0 -0
  32. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/filter.rs +0 -0
  33. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/generated/file_path_fields.rs +0 -0
  34. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/generated/mod.rs +0 -0
  35. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/generated/query_reference.rs +0 -0
  36. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/generated/updated_at_paths.rs +0 -0
  37. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/identity.rs +0 -0
  38. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/README.md +0 -0
  39. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/graph.rs +0 -0
  40. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/in_memory.rs +0 -0
  41. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/query.rs +0 -0
  42. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-core/src/registry/test_helpers.rs +0 -0
  43. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/Cargo.toml +0 -0
  44. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/README.md +0 -0
  45. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/conftest.py +0 -0
  46. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_batch.py +0 -0
  47. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_checksum.py +0 -0
  48. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_crud.py +0 -0
  49. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_errors.py +0 -0
  50. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_query.py +0 -0
  51. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_refresh.py +0 -0
  52. {snowflake_code_unit_registry-0.5.2 → snowflake_code_unit_registry-0.5.4}/crates/scai-state-python/tests/test_serialization.py +0 -0
@@ -834,9 +834,9 @@ dependencies = [
834
834
 
835
835
  [[package]]
836
836
  name = "hyper"
837
- version = "1.8.1"
837
+ version = "1.9.0"
838
838
  source = "registry+https://github.com/rust-lang/crates.io-index"
839
- checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
839
+ checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
840
840
  dependencies = [
841
841
  "atomic-waker",
842
842
  "bytes",
@@ -848,7 +848,6 @@ dependencies = [
848
848
  "httparse",
849
849
  "itoa",
850
850
  "pin-project-lite",
851
- "pin-utils",
852
851
  "smallvec",
853
852
  "tokio",
854
853
  "want",
@@ -1204,9 +1203,9 @@ dependencies = [
1204
1203
 
1205
1204
  [[package]]
1206
1205
  name = "js-sys"
1207
- version = "0.3.92"
1206
+ version = "0.3.94"
1208
1207
  source = "registry+https://github.com/rust-lang/crates.io-index"
1209
- checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
1208
+ checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
1210
1209
  dependencies = [
1211
1210
  "cfg-if",
1212
1211
  "futures-util",
@@ -1257,9 +1256,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
1257
1256
 
1258
1257
  [[package]]
1259
1258
  name = "libc"
1260
- version = "0.2.183"
1259
+ version = "0.2.184"
1261
1260
  source = "registry+https://github.com/rust-lang/crates.io-index"
1262
- checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
1261
+ checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
1263
1262
 
1264
1263
  [[package]]
1265
1264
  name = "libloading"
@@ -1617,12 +1616,6 @@ version = "0.2.17"
1617
1616
  source = "registry+https://github.com/rust-lang/crates.io-index"
1618
1617
  checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1619
1618
 
1620
- [[package]]
1621
- name = "pin-utils"
1622
- version = "0.1.0"
1623
- source = "registry+https://github.com/rust-lang/crates.io-index"
1624
- checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1625
-
1626
1619
  [[package]]
1627
1620
  name = "portable-atomic"
1628
1621
  version = "1.13.1"
@@ -2101,7 +2094,7 @@ dependencies = [
2101
2094
 
2102
2095
  [[package]]
2103
2096
  name = "scai-error-derive"
2104
- version = "0.5.2"
2097
+ version = "0.5.4"
2105
2098
  dependencies = [
2106
2099
  "err_code",
2107
2100
  "heck",
@@ -2115,7 +2108,7 @@ dependencies = [
2115
2108
 
2116
2109
  [[package]]
2117
2110
  name = "scai-state-core"
2118
- version = "0.5.2"
2111
+ version = "0.5.4"
2119
2112
  dependencies = [
2120
2113
  "chrono",
2121
2114
  "err_code",
@@ -2140,7 +2133,7 @@ dependencies = [
2140
2133
 
2141
2134
  [[package]]
2142
2135
  name = "scai-state-csharp"
2143
- version = "0.5.2"
2136
+ version = "0.5.4"
2144
2137
  dependencies = [
2145
2138
  "interoptopus",
2146
2139
  "interoptopus_backend_csharp",
@@ -2151,7 +2144,7 @@ dependencies = [
2151
2144
 
2152
2145
  [[package]]
2153
2146
  name = "scai-state-node"
2154
- version = "0.5.2"
2147
+ version = "0.5.4"
2155
2148
  dependencies = [
2156
2149
  "napi",
2157
2150
  "napi-build",
@@ -2163,7 +2156,7 @@ dependencies = [
2163
2156
 
2164
2157
  [[package]]
2165
2158
  name = "scai-state-python"
2166
- version = "0.5.2"
2159
+ version = "0.5.4"
2167
2160
  dependencies = [
2168
2161
  "pyo3",
2169
2162
  "pythonize",
@@ -2874,9 +2867,9 @@ dependencies = [
2874
2867
 
2875
2868
  [[package]]
2876
2869
  name = "wasm-bindgen"
2877
- version = "0.2.115"
2870
+ version = "0.2.117"
2878
2871
  source = "registry+https://github.com/rust-lang/crates.io-index"
2879
- checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
2872
+ checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
2880
2873
  dependencies = [
2881
2874
  "cfg-if",
2882
2875
  "once_cell",
@@ -2887,9 +2880,9 @@ dependencies = [
2887
2880
 
2888
2881
  [[package]]
2889
2882
  name = "wasm-bindgen-futures"
2890
- version = "0.4.65"
2883
+ version = "0.4.67"
2891
2884
  source = "registry+https://github.com/rust-lang/crates.io-index"
2892
- checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
2885
+ checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
2893
2886
  dependencies = [
2894
2887
  "js-sys",
2895
2888
  "wasm-bindgen",
@@ -2897,9 +2890,9 @@ dependencies = [
2897
2890
 
2898
2891
  [[package]]
2899
2892
  name = "wasm-bindgen-macro"
2900
- version = "0.2.115"
2893
+ version = "0.2.117"
2901
2894
  source = "registry+https://github.com/rust-lang/crates.io-index"
2902
- checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
2895
+ checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
2903
2896
  dependencies = [
2904
2897
  "quote",
2905
2898
  "wasm-bindgen-macro-support",
@@ -2907,9 +2900,9 @@ dependencies = [
2907
2900
 
2908
2901
  [[package]]
2909
2902
  name = "wasm-bindgen-macro-support"
2910
- version = "0.2.115"
2903
+ version = "0.2.117"
2911
2904
  source = "registry+https://github.com/rust-lang/crates.io-index"
2912
- checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
2905
+ checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
2913
2906
  dependencies = [
2914
2907
  "bumpalo",
2915
2908
  "proc-macro2",
@@ -2920,9 +2913,9 @@ dependencies = [
2920
2913
 
2921
2914
  [[package]]
2922
2915
  name = "wasm-bindgen-shared"
2923
- version = "0.2.115"
2916
+ version = "0.2.117"
2924
2917
  source = "registry+https://github.com/rust-lang/crates.io-index"
2925
- checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
2918
+ checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
2926
2919
  dependencies = [
2927
2920
  "unicode-ident",
2928
2921
  ]
@@ -2963,9 +2956,9 @@ dependencies = [
2963
2956
 
2964
2957
  [[package]]
2965
2958
  name = "web-sys"
2966
- version = "0.3.92"
2959
+ version = "0.3.94"
2967
2960
  source = "registry+https://github.com/rust-lang/crates.io-index"
2968
- checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
2961
+ checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
2969
2962
  dependencies = [
2970
2963
  "js-sys",
2971
2964
  "wasm-bindgen",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/scai-error-derive", "crates/scai-state-core", "crates/scai-state-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.5.2"
6
+ version = "0.5.4"
7
7
  edition = "2021"
8
8
  license = "SEE LICENSE IN LICENSE"
9
9
  repository = "https://github.com/snowflake-eng/scai-state"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-code-unit-registry
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: Apache Software License
@@ -0,0 +1,14 @@
1
+ # Schema History
2
+
3
+ Frozen snapshots of previous schema versions. Each snapshot captures the
4
+ schema as it was *before* bumping to the next version, providing a reference
5
+ for migration correctness and regression testing.
6
+
7
+ This directory is currently empty — it will contain its first snapshot when
8
+ `CURRENT_SCHEMA_VERSION` is bumped from 1 to 2.
9
+
10
+ **Rules:**
11
+
12
+ - Files are IMMUTABLE — never modify a frozen schema after it has been committed.
13
+ - Naming: `code-unit.v{N}.schema.json` (snapshot of the schema that was current for version N).
14
+ - See `schema-workflow.mdc` step 1 for when to create a snapshot.
@@ -168,6 +168,26 @@ pub enum Error {
168
168
  #[snafu(implicit)]
169
169
  location: snafu::Location,
170
170
  },
171
+
172
+ #[snafu(display(
173
+ "Schema version {document_version} is newer than supported version {supported_version}. \
174
+ Upgrade the library to read this document."
175
+ ))]
176
+ #[error_code(1020)]
177
+ SchemaVersionNewerThanSupported {
178
+ document_version: i64,
179
+ supported_version: i64,
180
+ #[snafu(implicit)]
181
+ location: snafu::Location,
182
+ },
183
+
184
+ #[snafu(display("Schema migration failed: {message}"))]
185
+ #[error_code(1021)]
186
+ SchemaMigrationError {
187
+ message: String,
188
+ #[snafu(implicit)]
189
+ location: snafu::Location,
190
+ },
171
191
  }
172
192
 
173
193
  impl ErrorTrace for Error {
@@ -234,7 +254,7 @@ pub struct TraceEntry {
234
254
 
235
255
  /// Bump this when adding or removing a variant.
236
256
  #[cfg(test)]
237
- const EXPECTED_VARIANT_COUNT: usize = 18;
257
+ const EXPECTED_VARIANT_COUNT: usize = 20;
238
258
 
239
259
  /// Result type alias for SCAI state operations
240
260
  pub type Result<T> = std::result::Result<T, Error>;
@@ -302,6 +322,8 @@ mod tests {
302
322
  #[test_case(WriteSucceededRefreshFailedSnafu.into_error(Box::new(ValidationSnafu { message: "msg" }.build())) => 1015 ; "WriteSucceededRefreshFailed")]
303
323
  #[test_case(ScopedRefreshRequiresFullRefreshSnafu { message: "run" }.build() => 1016 ; "ScopedRefreshRequiresFullRefresh")]
304
324
  #[test_case(ScopedRefreshUnresolvedDependencySnafu { id: "id" }.build() => 1017 ; "ScopedRefreshUnresolvedDependency")]
325
+ #[test_case(SchemaVersionNewerThanSupportedSnafu { document_version: 3i64, supported_version: 2i64 }.build() => 1020 ; "SchemaVersionNewerThanSupported")]
326
+ #[test_case(SchemaMigrationSnafu { message: "failed" }.build() => 1021 ; "SchemaMigrationError")]
305
327
  fn error_code_is_stable(err: Error) -> i32 {
306
328
  err.error_code_i32()
307
329
  }
@@ -9,6 +9,7 @@ pub mod error_trace;
9
9
  pub mod filter;
10
10
  pub mod generated;
11
11
  pub mod identity;
12
+ pub mod migration;
12
13
  pub mod registry;
13
14
  pub mod validation;
14
15
 
@@ -43,5 +44,8 @@ pub use validation::ValidationIssue;
43
44
  pub use validation::ValidationIssueKind;
44
45
  pub use validation::ValidationReport;
45
46
 
47
+ // Re-export migration constants
48
+ pub use migration::CURRENT_SCHEMA_VERSION;
49
+
46
50
  // Re-export identity helpers
47
51
  pub use identity::generate_id;
@@ -0,0 +1,390 @@
1
+ //! Schema version detection and document migration.
2
+ //!
3
+ //! Each code unit JSON file carries a `schemaVersion` integer. This module
4
+ //! provides the migration pipeline that upgrades old documents to the current
5
+ //! schema version using `serde_json::Value`-level transforms.
6
+ //!
7
+ //! ## Adding a new migration
8
+ //!
9
+ //! 1. Freeze `schemas/code-unit.schema.json` to `schemas/history/code-unit.vN.schema.json` (**before** editing the schema)
10
+ //! 2. Edit the schema — make structural changes and set `schemaVersion.const` to N+1
11
+ //! 3. Create `src/migration/versions/v{N}_to_v{N+1}.rs` with a `pub fn migrate(&mut Value) -> Result<()>`
12
+ //! 4. Register the module and function in `src/migration/versions/mod.rs`
13
+ //! 5. Update [`CURRENT_SCHEMA_VERSION`] to N+1
14
+ //! 6. Add migration tests with sample documents and both schema files
15
+
16
+ mod versions;
17
+
18
+ use serde_json::Value;
19
+
20
+ use crate::error::*;
21
+ use versions::{MigrationFn, MIGRATIONS};
22
+
23
+ /// JSON field name for the schema version indicator on every code unit document.
24
+ pub const SCHEMA_VERSION_FIELD: &str = "schemaVersion";
25
+
26
+ /// Current schema version supported by this library.
27
+ ///
28
+ /// Must match the `schemaVersion.const` value in `schemas/code-unit.schema.json`.
29
+ /// Guarded by `test_version_constant_matches_schema` — CI will fail if this
30
+ /// drifts from the schema file.
31
+ pub const CURRENT_SCHEMA_VERSION: i64 = 1;
32
+
33
+ /// Extract the schema version from a raw JSON document.
34
+ ///
35
+ /// - Returns `Ok(version)` when `schemaVersion` is a valid integer >= 1.
36
+ /// - Returns `Ok(1)` when `schemaVersion` is absent (missing = v1).
37
+ /// - Returns `Err(SchemaMigrationError)` when `schemaVersion` is present but not
38
+ /// a positive integer.
39
+ pub fn extract_version(doc: &Value) -> Result<i64> {
40
+ match doc.get(SCHEMA_VERSION_FIELD) {
41
+ None => Ok(1),
42
+ Some(v) => {
43
+ let version = v.as_i64().ok_or_else(|| {
44
+ SchemaMigrationSnafu {
45
+ message: format!(
46
+ "schemaVersion must be a positive integer, got: {}",
47
+ truncated_display(v),
48
+ ),
49
+ }
50
+ .build()
51
+ })?;
52
+ if version < 1 {
53
+ return SchemaMigrationSnafu {
54
+ message: format!("schemaVersion must be >= 1, got: {version}",),
55
+ }
56
+ .fail();
57
+ }
58
+ Ok(version)
59
+ }
60
+ }
61
+ }
62
+
63
+ /// Migrate a document from its current version to [`CURRENT_SCHEMA_VERSION`].
64
+ ///
65
+ /// - If the document is already at the current version, returns it unchanged.
66
+ /// - If the document version is newer than supported, returns
67
+ /// [`SchemaVersionNewerThanSupported`](Error::SchemaVersionNewerThanSupported).
68
+ /// - If a migration step fails, returns [`SchemaMigrationError`](Error::SchemaMigrationError).
69
+ /// - If the migration chain is incomplete (no path from old to current),
70
+ /// returns [`SchemaMigrationError`](Error::SchemaMigrationError).
71
+ pub fn migrate(doc: Value) -> Result<Value> {
72
+ migrate_with(doc, CURRENT_SCHEMA_VERSION, MIGRATIONS)
73
+ }
74
+
75
+ fn migrate_with(
76
+ mut doc: Value,
77
+ target_version: i64,
78
+ migrations: &[(i64, MigrationFn)],
79
+ ) -> Result<Value> {
80
+ let version = extract_version(&doc)?;
81
+
82
+ if version > target_version {
83
+ return SchemaVersionNewerThanSupportedSnafu {
84
+ document_version: version,
85
+ supported_version: target_version,
86
+ }
87
+ .fail();
88
+ }
89
+
90
+ if version == target_version {
91
+ return Ok(doc);
92
+ }
93
+
94
+ for &(from_ver, migrate_fn) in migrations {
95
+ if extract_version(&doc)? == from_ver {
96
+ migrate_fn(&mut doc).map_err(|e| {
97
+ SchemaMigrationSnafu {
98
+ message: format!("v{} to v{}: {e}", from_ver, from_ver + 1),
99
+ }
100
+ .build()
101
+ })?;
102
+ set_version(&mut doc, from_ver + 1)?;
103
+ }
104
+ }
105
+
106
+ let final_version = extract_version(&doc)?;
107
+ if final_version != target_version {
108
+ return SchemaMigrationSnafu {
109
+ message: format!(
110
+ "Migration chain incomplete: document at v{version}, reached v{final_version}, \
111
+ expected v{target_version}. Add missing migration functions.",
112
+ ),
113
+ }
114
+ .fail();
115
+ }
116
+
117
+ Ok(doc)
118
+ }
119
+
120
+ fn set_version(doc: &mut Value, version: i64) -> Result<()> {
121
+ let obj = doc.as_object_mut().ok_or_else(|| {
122
+ SchemaMigrationSnafu {
123
+ message: "document root must be a JSON object".to_string(),
124
+ }
125
+ .build()
126
+ })?;
127
+ obj.insert(SCHEMA_VERSION_FIELD.to_string(), Value::from(version));
128
+ Ok(())
129
+ }
130
+
131
+ fn truncated_display(v: &Value) -> String {
132
+ let s = v.to_string();
133
+ if s.len() > 50 {
134
+ format!("{}...", &s[..50])
135
+ } else {
136
+ s
137
+ }
138
+ }
139
+
140
+ #[cfg(test)]
141
+ mod tests {
142
+ use super::*;
143
+
144
+ #[test]
145
+ fn test_version_constant_matches_schema() {
146
+ let schema_str = include_str!("../../schemas/code-unit.schema.json");
147
+ let schema: Value = serde_json::from_str(schema_str).unwrap();
148
+ let schema_version = schema["properties"][SCHEMA_VERSION_FIELD]["const"]
149
+ .as_i64()
150
+ .expect("schemaVersion.const must be an integer in the schema");
151
+ assert_eq!(
152
+ CURRENT_SCHEMA_VERSION, schema_version,
153
+ "CURRENT_SCHEMA_VERSION ({CURRENT_SCHEMA_VERSION}) does not match \
154
+ schema's schemaVersion.const ({schema_version}). Update the constant.",
155
+ );
156
+ }
157
+
158
+ #[test]
159
+ fn test_extract_version_present() {
160
+ let doc: Value = serde_json::json!({(SCHEMA_VERSION_FIELD): 2});
161
+ assert_eq!(extract_version(&doc).unwrap(), 2);
162
+ }
163
+
164
+ #[test]
165
+ fn test_extract_version_missing_defaults_to_1() {
166
+ let doc: Value = serde_json::json!({"id": "test"});
167
+ assert_eq!(extract_version(&doc).unwrap(), 1);
168
+ }
169
+
170
+ #[test]
171
+ fn test_extract_version_non_integer_rejects_with_error() {
172
+ let doc: Value = serde_json::json!({(SCHEMA_VERSION_FIELD): "not_a_number"});
173
+ let err = extract_version(&doc).unwrap_err();
174
+ assert_eq!(err.error_code_i32(), 1021);
175
+ assert!(err.to_string().contains("must be a positive integer"));
176
+ }
177
+
178
+ #[test]
179
+ fn test_extract_version_float_rejects_with_error() {
180
+ let doc: Value = serde_json::json!({(SCHEMA_VERSION_FIELD): 1.5});
181
+ let err = extract_version(&doc).unwrap_err();
182
+ assert_eq!(err.error_code_i32(), 1021);
183
+ }
184
+
185
+ #[test]
186
+ fn test_extract_version_non_positive_rejects_with_error() {
187
+ for &v in &[0, -5] {
188
+ let doc: Value = serde_json::json!({(SCHEMA_VERSION_FIELD): v});
189
+ let err = extract_version(&doc).unwrap_err();
190
+ assert_eq!(err.error_code_i32(), 1021, "schemaVersion={v}");
191
+ assert!(
192
+ err.to_string().contains("must be >= 1"),
193
+ "schemaVersion={v}"
194
+ );
195
+ }
196
+ }
197
+
198
+ #[test]
199
+ fn test_migrate_current_version_is_noop() {
200
+ let doc: Value =
201
+ serde_json::json!({(SCHEMA_VERSION_FIELD): CURRENT_SCHEMA_VERSION, "id": "test"});
202
+ let result = migrate(doc.clone()).unwrap();
203
+ assert_eq!(result, doc);
204
+ }
205
+
206
+ #[test]
207
+ fn test_migrate_future_version_rejected() {
208
+ let doc: Value = serde_json::json!({(SCHEMA_VERSION_FIELD): CURRENT_SCHEMA_VERSION + 1});
209
+ let err = migrate(doc).unwrap_err();
210
+ assert_eq!(err.error_code_i32(), 1020);
211
+ let msg = err.to_string();
212
+ assert!(
213
+ msg.contains("newer"),
214
+ "error message should mention 'newer': {msg}"
215
+ );
216
+ }
217
+
218
+ #[test]
219
+ fn test_migrate_non_integer_version_rejected() {
220
+ let doc: Value = serde_json::json!({(SCHEMA_VERSION_FIELD): "bad"});
221
+ let err = migrate(doc).unwrap_err();
222
+ assert_eq!(err.error_code_i32(), 1021);
223
+ }
224
+
225
+ #[test]
226
+ fn test_migrate_non_positive_version_rejected() {
227
+ for &v in &[0, -3] {
228
+ let doc: Value = serde_json::json!({(SCHEMA_VERSION_FIELD): v});
229
+ let err = migrate(doc).unwrap_err();
230
+ assert_eq!(err.error_code_i32(), 1021, "schemaVersion={v}");
231
+ assert!(
232
+ err.to_string().contains("must be >= 1"),
233
+ "schemaVersion={v}"
234
+ );
235
+ }
236
+ }
237
+
238
+ // ── Pipeline tests using test-only migration functions ─────────────
239
+ //
240
+ // Test schema versions:
241
+ // v1 → v2: adds "addedInV2": "hello"
242
+ // v2 → v3: renames "legacyField" → "renamedField"
243
+
244
+ /// v1→v2: adds a new field.
245
+ fn fake_v1_to_v2(doc: &mut Value) -> Result<()> {
246
+ if let Some(obj) = doc.as_object_mut() {
247
+ obj.insert("addedInV2".to_string(), Value::from("hello"));
248
+ }
249
+ Ok(())
250
+ }
251
+
252
+ /// v2→v3: renames a field.
253
+ fn fake_v2_to_v3(doc: &mut Value) -> Result<()> {
254
+ if let Some(obj) = doc.as_object_mut() {
255
+ if let Some(old) = obj.remove("legacyField") {
256
+ obj.insert("renamedField".to_string(), old);
257
+ }
258
+ }
259
+ Ok(())
260
+ }
261
+
262
+ /// Always fails — used to test error propagation.
263
+ fn fake_failing_migration(doc: &mut Value) -> Result<()> {
264
+ let _ = doc;
265
+ SchemaMigrationSnafu {
266
+ message: "simulated failure".to_string(),
267
+ }
268
+ .fail()
269
+ }
270
+
271
+ /// Full v1→v2→v3 chain.
272
+ fn full_chain() -> &'static [(i64, MigrationFn)] {
273
+ &[(1, fake_v1_to_v2), (2, fake_v2_to_v3)]
274
+ }
275
+
276
+ /// Minimal document at a given schema version.
277
+ fn doc_at_version(version: i64) -> Value {
278
+ serde_json::json!({
279
+ (SCHEMA_VERSION_FIELD): version,
280
+ "id": "unit-1"
281
+ })
282
+ }
283
+
284
+ /// v1 document with a field that v2→v3 will rename.
285
+ fn v1_doc_with_legacy_field() -> Value {
286
+ serde_json::json!({
287
+ (SCHEMA_VERSION_FIELD): 1,
288
+ "id": "unit-1",
289
+ "legacyField": "keep-me"
290
+ })
291
+ }
292
+
293
+ /// v1 document with extra fields to verify they survive migration.
294
+ fn v1_doc_with_extra_fields() -> Value {
295
+ serde_json::json!({
296
+ (SCHEMA_VERSION_FIELD): 1,
297
+ "id": "unit-1",
298
+ "customData": {"nested": true},
299
+ "tags": ["a", "b"]
300
+ })
301
+ }
302
+
303
+ #[test]
304
+ fn test_pipeline_single_step_v1_to_v2() {
305
+ let doc = doc_at_version(1);
306
+
307
+ let result = migrate_with(doc, 2, &[(1, fake_v1_to_v2)]).unwrap();
308
+
309
+ assert_eq!(result[SCHEMA_VERSION_FIELD], 2);
310
+ assert_eq!(result["addedInV2"], "hello");
311
+ assert_eq!(result["id"], "unit-1");
312
+ }
313
+
314
+ #[test]
315
+ fn test_pipeline_two_steps_v1_to_v3() {
316
+ let doc = v1_doc_with_legacy_field();
317
+
318
+ let result = migrate_with(doc, 3, full_chain()).unwrap();
319
+
320
+ assert_eq!(result[SCHEMA_VERSION_FIELD], 3);
321
+ assert_eq!(result["addedInV2"], "hello");
322
+ assert_eq!(result["renamedField"], "keep-me");
323
+ assert!(result.get("legacyField").is_none());
324
+ }
325
+
326
+ #[test]
327
+ fn test_pipeline_starts_from_middle_version() {
328
+ let doc = doc_at_version(2);
329
+
330
+ let result = migrate_with(doc, 3, full_chain()).unwrap();
331
+
332
+ assert_eq!(result[SCHEMA_VERSION_FIELD], 3);
333
+ assert!(
334
+ result.get("addedInV2").is_none(),
335
+ "v1→v2 should not have run"
336
+ );
337
+ }
338
+
339
+ #[test]
340
+ fn test_pipeline_incomplete_chain_reports_error() {
341
+ let doc = doc_at_version(1);
342
+
343
+ let err = migrate_with(doc, 3, &[(1, fake_v1_to_v2)]).unwrap_err();
344
+
345
+ assert_eq!(err.error_code_i32(), 1021);
346
+ assert!(err.to_string().contains("Migration chain incomplete"));
347
+ assert!(err.to_string().contains("reached v2"));
348
+ assert!(err.to_string().contains("expected v3"));
349
+ }
350
+
351
+ #[test]
352
+ fn test_pipeline_migration_failure_stops_chain() {
353
+ let chain: &[(i64, MigrationFn)] = &[(1, fake_failing_migration), (2, fake_v2_to_v3)];
354
+ let doc = doc_at_version(1);
355
+
356
+ let err = migrate_with(doc, 3, chain).unwrap_err();
357
+
358
+ assert_eq!(err.error_code_i32(), 1021);
359
+ assert!(err.to_string().contains("v1 to v2"));
360
+ assert!(err.to_string().contains("simulated failure"));
361
+ }
362
+
363
+ #[test]
364
+ fn test_pipeline_already_at_target_is_noop() {
365
+ let doc = doc_at_version(2);
366
+
367
+ let result = migrate_with(doc.clone(), 2, &[(1, fake_v1_to_v2)]).unwrap();
368
+
369
+ assert_eq!(result, doc);
370
+ }
371
+
372
+ #[test]
373
+ fn test_set_version_rejects_non_object_root() {
374
+ let mut doc = Value::from("not-an-object");
375
+ let err = set_version(&mut doc, 2).unwrap_err();
376
+ assert_eq!(err.error_code_i32(), 1021);
377
+ assert!(err.to_string().contains("must be a JSON object"));
378
+ }
379
+
380
+ #[test]
381
+ fn test_pipeline_preserves_unrelated_fields() {
382
+ let doc = v1_doc_with_extra_fields();
383
+
384
+ let result = migrate_with(doc, 3, full_chain()).unwrap();
385
+
386
+ assert_eq!(result["customData"]["nested"], true);
387
+ assert_eq!(result["tags"][0], "a");
388
+ assert_eq!(result["tags"][1], "b");
389
+ }
390
+ }
@@ -0,0 +1,32 @@
1
+ //! Individual migration functions, one per schema version transition.
2
+ //!
3
+ //! Each migration lives in its own file named `v{N}_to_v{N+1}.rs` and
4
+ //! exports a single public function:
5
+ //!
6
+ //! ```ignore
7
+ //! pub fn migrate(doc: &mut Value) -> Result<()> { ... }
8
+ //! ```
9
+ //!
10
+ //! ## Adding a new migration
11
+ //!
12
+ //! 1. Freeze the current schema to `schemas/history/code-unit.v{N}.schema.json` (**before** editing).
13
+ //! 2. Create `migration/versions/v{N}_to_v{N+1}.rs`.
14
+ //! 3. Implement `pub fn migrate(doc: &mut Value) -> Result<()>`.
15
+ //! 4. Add `mod v{N}_to_v{N+1};` below.
16
+ //! 5. Add `(N, v{N}_to_v{N+1}::migrate)` to [`MIGRATIONS`].
17
+ //! 6. Bump [`CURRENT_SCHEMA_VERSION`](super::CURRENT_SCHEMA_VERSION).
18
+
19
+ use serde_json::Value;
20
+
21
+ use crate::error::*;
22
+
23
+ pub(crate) type MigrationFn = fn(&mut Value) -> Result<()>;
24
+
25
+ // ── Migration modules ──────────────────────────────────────────────────
26
+ // mod v1_to_v2;
27
+
28
+ /// Ordered migration chain. Each entry is `(from_version, migration_fn)`.
29
+ /// A migration from version N always produces version N+1.
30
+ pub(crate) const MIGRATIONS: &[(i64, MigrationFn)] = &[
31
+ // (1, v1_to_v2::migrate),
32
+ ];