@lix-js/sdk 0.6.0-preview.3 → 0.6.0-preview.5

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 (235) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +105 -65
  3. package/dist/engine-wasm/index.js +4 -4
  4. package/dist/engine-wasm/wasm/lix_engine.d.ts +30 -6
  5. package/dist/engine-wasm/wasm/lix_engine.js +187 -117
  6. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  7. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +14 -8
  8. package/dist/generated/builtin-schemas.d.ts +69 -69
  9. package/dist/generated/builtin-schemas.js +94 -94
  10. package/dist/open-lix.d.ts +42 -28
  11. package/dist/open-lix.js +49 -10
  12. package/dist/sqlite/index.js +86 -30
  13. package/dist-engine-src/README.md +3 -3
  14. package/dist-engine-src/src/backend/capabilities.rs +67 -0
  15. package/dist-engine-src/src/backend/conformance/baseline.rs +1127 -0
  16. package/dist-engine-src/src/backend/conformance/factory.rs +93 -0
  17. package/dist-engine-src/src/backend/conformance/failure_tests.rs +608 -0
  18. package/dist-engine-src/src/backend/conformance/fixtures.rs +26 -0
  19. package/dist-engine-src/src/backend/conformance/mod.rs +75 -0
  20. package/dist-engine-src/src/backend/conformance/model.rs +28 -0
  21. package/dist-engine-src/src/backend/conformance/model_based.rs +257 -0
  22. package/dist-engine-src/src/backend/conformance/persistence.rs +204 -0
  23. package/dist-engine-src/src/backend/conformance/projection.rs +21 -0
  24. package/dist-engine-src/src/backend/conformance/pushdown.rs +24 -0
  25. package/dist-engine-src/src/backend/conformance/runner.rs +90 -0
  26. package/dist-engine-src/src/backend/conformance/scan.rs +24 -0
  27. package/dist-engine-src/src/backend/conformance/write.rs +16 -0
  28. package/dist-engine-src/src/backend/error.rs +94 -0
  29. package/dist-engine-src/src/backend/in_memory.rs +670 -0
  30. package/dist-engine-src/src/backend/mod.rs +36 -9
  31. package/dist-engine-src/src/backend/predicate.rs +80 -0
  32. package/dist-engine-src/src/backend/traits.rs +260 -0
  33. package/dist-engine-src/src/backend/types.rs +224 -81
  34. package/dist-engine-src/src/binary_cas/context.rs +8 -8
  35. package/dist-engine-src/src/binary_cas/kv.rs +234 -259
  36. package/dist-engine-src/src/{version → branch}/context.rs +12 -12
  37. package/dist-engine-src/src/branch/lifecycle.rs +221 -0
  38. package/dist-engine-src/src/branch/mod.rs +13 -0
  39. package/dist-engine-src/src/branch/refs.rs +321 -0
  40. package/dist-engine-src/src/branch/stage_rows.rs +67 -0
  41. package/dist-engine-src/src/branch/types.rs +21 -0
  42. package/dist-engine-src/src/catalog/context.rs +18 -18
  43. package/dist-engine-src/src/catalog/snapshot.rs +8 -8
  44. package/dist-engine-src/src/changelog/bench_support.rs +785 -0
  45. package/dist-engine-src/src/changelog/change.rs +1 -0
  46. package/dist-engine-src/src/changelog/codec.rs +497 -0
  47. package/dist-engine-src/src/changelog/commit.rs +1 -0
  48. package/dist-engine-src/src/changelog/context.rs +1614 -0
  49. package/dist-engine-src/src/changelog/mod.rs +29 -0
  50. package/dist-engine-src/src/changelog/store.rs +163 -0
  51. package/dist-engine-src/src/changelog/test_support.rs +54 -0
  52. package/dist-engine-src/src/changelog/types.rs +213 -0
  53. package/dist-engine-src/src/commit_graph/context.rs +317 -274
  54. package/dist-engine-src/src/commit_graph/mod.rs +2 -4
  55. package/dist-engine-src/src/commit_graph/types.rs +22 -42
  56. package/dist-engine-src/src/commit_graph/walker.rs +133 -103
  57. package/dist-engine-src/src/common/error.rs +52 -18
  58. package/dist-engine-src/src/common/identity.rs +2 -2
  59. package/dist-engine-src/src/common/mod.rs +1 -1
  60. package/dist-engine-src/src/domain.rs +42 -46
  61. package/dist-engine-src/src/engine.rs +74 -96
  62. package/dist-engine-src/src/{entity_identity.rs → entity_pk.rs} +89 -92
  63. package/dist-engine-src/src/functions/context.rs +56 -52
  64. package/dist-engine-src/src/functions/state.rs +51 -52
  65. package/dist-engine-src/src/init.rs +288 -154
  66. package/dist-engine-src/src/json_store/context.rs +15 -266
  67. package/dist-engine-src/src/json_store/mod.rs +26 -0
  68. package/dist-engine-src/src/json_store/store.rs +103 -718
  69. package/dist-engine-src/src/json_store/types.rs +4 -9
  70. package/dist-engine-src/src/lib.rs +49 -19
  71. package/dist-engine-src/src/live_state/context.rs +654 -790
  72. package/dist-engine-src/src/live_state/mod.rs +9 -3
  73. package/dist-engine-src/src/live_state/overlay.rs +4 -4
  74. package/dist-engine-src/src/live_state/types.rs +30 -21
  75. package/dist-engine-src/src/live_state/visibility.rs +514 -71
  76. package/dist-engine-src/src/plugin/install.rs +48 -48
  77. package/dist-engine-src/src/plugin/manifest.rs +7 -7
  78. package/dist-engine-src/src/plugin/materializer.rs +0 -275
  79. package/dist-engine-src/src/plugin/plugin_manifest.json +4 -3
  80. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +2 -2
  81. package/dist-engine-src/src/schema/builtin/lix_branch_descriptor.json +34 -0
  82. package/dist-engine-src/src/schema/builtin/lix_branch_ref.json +48 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change.json +3 -3
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +1 -1
  85. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +6 -6
  86. package/dist-engine-src/src/schema/builtin/mod.rs +18 -20
  87. package/dist-engine-src/src/schema/compatibility.rs +11 -11
  88. package/dist-engine-src/src/schema/definition.json +2 -2
  89. package/dist-engine-src/src/schema/definition.rs +5 -5
  90. package/dist-engine-src/src/schema/key.rs +3 -3
  91. package/dist-engine-src/src/schema/mod.rs +1 -1
  92. package/dist-engine-src/src/schema/tests.rs +18 -18
  93. package/dist-engine-src/src/session/context.rs +819 -124
  94. package/dist-engine-src/src/session/create_branch.rs +94 -0
  95. package/dist-engine-src/src/session/execute.rs +260 -57
  96. package/dist-engine-src/src/session/merge/analysis.rs +9 -3
  97. package/dist-engine-src/src/session/merge/{version.rs → branch.rs} +119 -129
  98. package/dist-engine-src/src/session/merge/conflicts.rs +2 -2
  99. package/dist-engine-src/src/session/merge/mod.rs +5 -6
  100. package/dist-engine-src/src/session/merge/stats.rs +7 -11
  101. package/dist-engine-src/src/session/mod.rs +19 -16
  102. package/dist-engine-src/src/session/switch_branch.rs +113 -0
  103. package/dist-engine-src/src/session/transaction.rs +557 -0
  104. package/dist-engine-src/src/sql2/bind/classify.rs +102 -0
  105. package/dist-engine-src/src/sql2/bind/error.rs +5 -0
  106. package/dist-engine-src/src/sql2/bind/expr.rs +29 -0
  107. package/dist-engine-src/src/sql2/bind/mod.rs +12 -0
  108. package/dist-engine-src/src/sql2/{udfs/public_call.rs → bind/public_udf.rs} +98 -3
  109. package/dist-engine-src/src/sql2/bind/read.rs +65 -0
  110. package/dist-engine-src/src/sql2/bind/statement.rs +2236 -0
  111. package/dist-engine-src/src/sql2/bind/table.rs +273 -0
  112. package/dist-engine-src/src/sql2/bind/write.rs +86 -0
  113. package/dist-engine-src/src/sql2/branch_scope.rs +436 -0
  114. package/dist-engine-src/src/sql2/catalog/capability.rs +20 -0
  115. package/dist-engine-src/src/sql2/catalog/entity_surface.rs +296 -0
  116. package/dist-engine-src/src/sql2/catalog/mod.rs +15 -0
  117. package/dist-engine-src/src/sql2/catalog/registry.rs +556 -0
  118. package/dist-engine-src/src/sql2/catalog/schema.rs +88 -0
  119. package/dist-engine-src/src/sql2/catalog/surface.rs +41 -0
  120. package/dist-engine-src/src/sql2/change_materialization.rs +122 -0
  121. package/dist-engine-src/src/sql2/context.rs +36 -30
  122. package/dist-engine-src/src/sql2/error.rs +4 -5
  123. package/dist-engine-src/src/sql2/exec/bound_public_write.rs +1593 -0
  124. package/dist-engine-src/src/sql2/exec/datafusion.rs +5266 -0
  125. package/dist-engine-src/src/sql2/exec/fast_write.rs +82 -0
  126. package/dist-engine-src/src/sql2/exec/mod.rs +24 -0
  127. package/dist-engine-src/src/sql2/exec/write.rs +661 -0
  128. package/dist-engine-src/src/sql2/filesystem_planner.rs +72 -77
  129. package/dist-engine-src/src/sql2/filesystem_visibility.rs +21 -21
  130. package/dist-engine-src/src/sql2/history_projection.rs +8 -8
  131. package/dist-engine-src/src/sql2/history_route.rs +35 -31
  132. package/dist-engine-src/src/sql2/mod.rs +30 -24
  133. package/dist-engine-src/src/sql2/optimize/datafusion.rs +1 -0
  134. package/dist-engine-src/src/sql2/optimize/mod.rs +2 -0
  135. package/dist-engine-src/src/sql2/optimize/simple_write.rs +116 -0
  136. package/dist-engine-src/src/sql2/parse/mod.rs +69 -0
  137. package/dist-engine-src/src/sql2/parse/normalize.rs +1 -0
  138. package/dist-engine-src/src/sql2/plan/branch_scope.rs +24 -0
  139. package/dist-engine-src/src/sql2/plan/mod.rs +5 -0
  140. package/dist-engine-src/src/sql2/plan/predicate.rs +22 -0
  141. package/dist-engine-src/src/sql2/plan/write.rs +147 -0
  142. package/dist-engine-src/src/sql2/predicate_typecheck.rs +258 -0
  143. package/dist-engine-src/src/sql2/{version_provider.rs → providers/branch.rs} +218 -214
  144. package/dist-engine-src/src/sql2/{change_provider.rs → providers/change.rs} +156 -42
  145. package/dist-engine-src/src/sql2/{directory_provider.rs → providers/directory.rs} +291 -322
  146. package/dist-engine-src/src/sql2/{directory_history_provider.rs → providers/directory_history.rs} +56 -42
  147. package/dist-engine-src/src/sql2/providers/entity.rs +1484 -0
  148. package/dist-engine-src/src/sql2/{entity_history_provider.rs → providers/entity_history.rs} +43 -31
  149. package/dist-engine-src/src/sql2/{file_provider.rs → providers/file.rs} +323 -316
  150. package/dist-engine-src/src/sql2/{file_history_provider.rs → providers/file_history.rs} +60 -46
  151. package/dist-engine-src/src/sql2/{history_provider.rs → providers/history.rs} +46 -32
  152. package/dist-engine-src/src/sql2/{lix_state_provider.rs → providers/lix_state.rs} +359 -329
  153. package/dist-engine-src/src/sql2/providers/mod.rs +508 -0
  154. package/dist-engine-src/src/sql2/read_only.rs +2 -2
  155. package/dist-engine-src/src/sql2/session.rs +47 -96
  156. package/dist-engine-src/src/sql2/storage/constraints.rs +1 -0
  157. package/dist-engine-src/src/sql2/storage/mod.rs +1 -0
  158. package/dist-engine-src/src/sql2/test_support/differential.rs +712 -0
  159. package/dist-engine-src/src/sql2/test_support/generators.rs +354 -0
  160. package/dist-engine-src/src/sql2/test_support/mod.rs +2 -0
  161. package/dist-engine-src/src/sql2/udfs/{lix_active_version_commit_id.rs → lix_active_branch_commit_id.rs} +7 -7
  162. package/dist-engine-src/src/sql2/udfs/mod.rs +3 -6
  163. package/dist-engine-src/src/sql2/write_normalization.rs +45 -22
  164. package/dist-engine-src/src/storage/conformance.rs +399 -0
  165. package/dist-engine-src/src/storage/context.rs +552 -288
  166. package/dist-engine-src/src/storage/mod.rs +48 -10
  167. package/dist-engine-src/src/storage/point.rs +440 -0
  168. package/dist-engine-src/src/storage/read_scope.rs +43 -64
  169. package/dist-engine-src/src/storage/reader.rs +867 -0
  170. package/dist-engine-src/src/storage/scan.rs +784 -0
  171. package/dist-engine-src/src/storage/spaces.rs +236 -0
  172. package/dist-engine-src/src/storage/stats.rs +80 -0
  173. package/dist-engine-src/src/storage/write_set.rs +962 -0
  174. package/dist-engine-src/src/storage_bench.rs +136 -4828
  175. package/dist-engine-src/src/test_support.rs +360 -138
  176. package/dist-engine-src/src/tracked_state/bench_support.rs +394 -0
  177. package/dist-engine-src/src/tracked_state/codec.rs +155 -1057
  178. package/dist-engine-src/src/tracked_state/commit_root_rebuild.rs +358 -0
  179. package/dist-engine-src/src/tracked_state/context.rs +1927 -993
  180. package/dist-engine-src/src/tracked_state/diff.rs +1715 -261
  181. package/dist-engine-src/src/tracked_state/merge.rs +74 -88
  182. package/dist-engine-src/src/tracked_state/mod.rs +19 -16
  183. package/dist-engine-src/src/tracked_state/{materialization.rs → row_materialization.rs} +50 -178
  184. package/dist-engine-src/src/tracked_state/storage.rs +243 -191
  185. package/dist-engine-src/src/tracked_state/tree.rs +247 -371
  186. package/dist-engine-src/src/tracked_state/types.rs +49 -42
  187. package/dist-engine-src/src/transaction/bench_support.rs +407 -0
  188. package/dist-engine-src/src/transaction/commit.rs +821 -713
  189. package/dist-engine-src/src/transaction/context.rs +705 -600
  190. package/dist-engine-src/src/transaction/mod.rs +13 -2
  191. package/dist-engine-src/src/transaction/normalization.rs +63 -76
  192. package/dist-engine-src/src/transaction/prep.rs +13 -13
  193. package/dist-engine-src/src/transaction/schema_resolver.rs +19 -5
  194. package/dist-engine-src/src/transaction/staging.rs +228 -434
  195. package/dist-engine-src/src/transaction/types.rs +41 -98
  196. package/dist-engine-src/src/transaction/validation.rs +382 -446
  197. package/dist-engine-src/src/untracked_state/codec.rs +337 -29
  198. package/dist-engine-src/src/untracked_state/context.rs +7 -7
  199. package/dist-engine-src/src/untracked_state/materialization.rs +2 -2
  200. package/dist-engine-src/src/untracked_state/mod.rs +1 -1
  201. package/dist-engine-src/src/untracked_state/storage.rs +659 -157
  202. package/dist-engine-src/src/untracked_state/types.rs +21 -21
  203. package/package.json +71 -68
  204. package/dist-engine-src/src/backend/kv.rs +0 -358
  205. package/dist-engine-src/src/backend/testing.rs +0 -658
  206. package/dist-engine-src/src/commit_store/codec.rs +0 -887
  207. package/dist-engine-src/src/commit_store/context.rs +0 -944
  208. package/dist-engine-src/src/commit_store/materialization.rs +0 -84
  209. package/dist-engine-src/src/commit_store/mod.rs +0 -16
  210. package/dist-engine-src/src/commit_store/storage.rs +0 -600
  211. package/dist-engine-src/src/commit_store/types.rs +0 -215
  212. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -34
  213. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -48
  214. package/dist-engine-src/src/session/create_version.rs +0 -88
  215. package/dist-engine-src/src/session/merge/apply.rs +0 -23
  216. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +0 -100
  217. package/dist-engine-src/src/session/switch_version.rs +0 -109
  218. package/dist-engine-src/src/sql2/classify.rs +0 -182
  219. package/dist-engine-src/src/sql2/entity_provider.rs +0 -3211
  220. package/dist-engine-src/src/sql2/execute.rs +0 -3440
  221. package/dist-engine-src/src/sql2/public_bind/assignment.rs +0 -46
  222. package/dist-engine-src/src/sql2/public_bind/capability.rs +0 -41
  223. package/dist-engine-src/src/sql2/public_bind/dml.rs +0 -166
  224. package/dist-engine-src/src/sql2/public_bind/mod.rs +0 -25
  225. package/dist-engine-src/src/sql2/public_bind/table.rs +0 -168
  226. package/dist-engine-src/src/sql2/version_scope.rs +0 -394
  227. package/dist-engine-src/src/storage/types.rs +0 -501
  228. package/dist-engine-src/src/tracked_state/by_file_index.rs +0 -98
  229. package/dist-engine-src/src/tracked_state/materializer.rs +0 -488
  230. package/dist-engine-src/src/transaction/live_state_overlay.rs +0 -35
  231. package/dist-engine-src/src/version/lifecycle.rs +0 -221
  232. package/dist-engine-src/src/version/mod.rs +0 -13
  233. package/dist-engine-src/src/version/refs.rs +0 -330
  234. package/dist-engine-src/src/version/stage_rows.rs +0 -67
  235. package/dist-engine-src/src/version/types.rs +0 -21
@@ -1,70 +1,76 @@
1
1
  use std::future::Future;
2
2
  use std::pin::Pin;
3
- use std::sync::atomic::{AtomicBool, Ordering};
4
3
  use std::sync::Arc;
5
4
 
6
5
  use serde_json::Value as JsonValue;
7
6
 
8
7
  use crate::binary_cas::{BinaryCasContext, BlobDataReader};
8
+ use crate::branch::{
9
+ BranchContext, BranchLifecycle, BranchOperation, BranchRefReader, BranchReferenceRole,
10
+ };
9
11
  use crate::catalog::CatalogContext;
10
12
  use crate::commit_graph::{CommitGraphContext, CommitGraphReader};
11
- use crate::commit_store::CommitStoreContext;
12
- use crate::entity_identity::EntityIdentity;
13
+ use crate::entity_pk::EntityPk;
13
14
  use crate::functions::FunctionProviderHandle;
14
15
  use crate::json_store::JsonStoreContext;
15
16
  use crate::live_state::{LiveStateContext, LiveStateReader, LiveStateRowRequest};
16
- use crate::sql2::{CommitStoreQuerySource, SqlCommitStoreQuerySource, SqlExecutionContext};
17
+ use crate::sql2::{
18
+ ChangelogQuerySource, HistoryQuerySource, SqlChangelogQuerySource, SqlExecutionContext,
19
+ SqlHistoryQuerySource,
20
+ };
17
21
  use crate::storage::{
18
- ScopedStorageReader, StorageContext, StorageReadScope, StorageReadTransaction, StorageReader,
22
+ DurableWriteGuard, DurableWriteLock, InMemoryStorageBackend, StorageBackend, StorageReadOptions,
19
23
  };
24
+ use crate::storage::{StorageContext, StorageRead, StorageReadScope};
20
25
  use crate::tracked_state::TrackedStateContext;
21
26
  use crate::transaction::{open_transaction, Transaction};
22
- use crate::version::{
23
- VersionContext, VersionLifecycle, VersionOperation, VersionRefReader, VersionReferenceRole,
24
- };
25
- use crate::GLOBAL_VERSION_ID;
27
+ use crate::GLOBAL_BRANCH_ID;
26
28
  use crate::{LixError, NullableKeyFilter};
27
29
 
28
- pub(crate) const WORKSPACE_VERSION_KEY: &str = "lix_workspace_version_id";
30
+ use super::transaction::{SessionOperationGuard, SessionTransactionManager, SessionWriteLease};
31
+
32
+ pub(crate) const WORKSPACE_BRANCH_KEY: &str = "lix_workspace_branch_id";
29
33
 
30
34
  #[derive(Clone)]
31
35
  pub(crate) enum SessionMode {
32
- Pinned { version_id: String },
36
+ Pinned { branch_id: String },
33
37
  Workspace,
34
38
  }
35
39
 
36
40
  /// Session-context state for engine execution.
37
41
  ///
38
- /// A session context pins the active version selector and shared execution
39
- /// services. Each call to `execute(...)` projects this state into a read-only
40
- /// SQL context or a transaction-owned write context.
41
- ///
42
- /// Write transaction invariant: any engine operation that may write must enter
43
- /// through `SessionContext::with_write_transaction`. Reads that influence writes
44
- /// are only available from that transaction capability, not from session-level
45
- /// helpers.
42
+ /// A session context pins the active branch selector and shared execution
43
+ /// services. Parent-handle `execute(...)` runs as an implicit single-statement
44
+ /// transaction. Explicit transactions hold the session execution lease until
45
+ /// commit or rollback, so all SQL during that window must run through the
46
+ /// transaction handle.
46
47
  #[derive(Clone)]
47
- pub struct SessionContext {
48
+ pub struct SessionContext<B: StorageBackend = InMemoryStorageBackend> {
48
49
  pub(super) mode: SessionMode,
49
- pub(super) storage: StorageContext,
50
+ pub(super) storage: StorageContext<B>,
50
51
  pub(super) live_state: Arc<LiveStateContext>,
51
52
  pub(super) tracked_state: Arc<TrackedStateContext>,
52
53
  pub(super) binary_cas: Arc<BinaryCasContext>,
53
- pub(super) commit_store: Arc<CommitStoreContext>,
54
- pub(super) version_ctx: Arc<VersionContext>,
54
+ pub(super) branch_ctx: Arc<BranchContext>,
55
55
  pub(super) catalog_context: Arc<CatalogContext>,
56
- closed: Arc<AtomicBool>,
56
+ pub(super) write_lock: DurableWriteLock,
57
+ transaction_manager: SessionTransactionManager,
57
58
  }
58
59
 
59
- impl SessionContext {
60
+ impl<B> SessionContext<B>
61
+ where
62
+ B: StorageBackend + Clone + Send + Sync + 'static,
63
+ for<'backend> B::Read<'backend>: Clone + Send + Sync + 'static,
64
+ for<'backend> B::Write<'backend>: Send,
65
+ {
60
66
  pub(crate) async fn open_workspace(
61
- storage: StorageContext,
67
+ storage: StorageContext<B>,
62
68
  live_state: Arc<LiveStateContext>,
63
69
  tracked_state: Arc<TrackedStateContext>,
64
70
  binary_cas: Arc<BinaryCasContext>,
65
- commit_store: Arc<CommitStoreContext>,
66
- version_ctx: Arc<VersionContext>,
71
+ branch_ctx: Arc<BranchContext>,
67
72
  catalog_context: Arc<CatalogContext>,
73
+ write_lock: DurableWriteLock,
68
74
  ) -> Result<Self, LixError> {
69
75
  let session = Self::new(
70
76
  SessionMode::Workspace,
@@ -72,71 +78,71 @@ impl SessionContext {
72
78
  live_state,
73
79
  tracked_state,
74
80
  binary_cas,
75
- commit_store,
76
- version_ctx,
81
+ branch_ctx,
77
82
  catalog_context,
83
+ write_lock,
78
84
  );
79
- session.active_version_id().await?;
85
+ session.active_branch_id().await?;
80
86
  Ok(session)
81
87
  }
82
88
 
83
89
  pub(crate) async fn open(
84
- active_version_id: String,
85
- storage: StorageContext,
90
+ active_branch_id: String,
91
+ storage: StorageContext<B>,
86
92
  live_state: Arc<LiveStateContext>,
87
93
  tracked_state: Arc<TrackedStateContext>,
88
94
  binary_cas: Arc<BinaryCasContext>,
89
- commit_store: Arc<CommitStoreContext>,
90
- version_ctx: Arc<VersionContext>,
95
+ branch_ctx: Arc<BranchContext>,
91
96
  catalog_context: Arc<CatalogContext>,
97
+ write_lock: DurableWriteLock,
92
98
  ) -> Result<Self, LixError> {
93
99
  Ok(Self::new(
94
100
  SessionMode::Pinned {
95
- version_id: active_version_id,
101
+ branch_id: active_branch_id,
96
102
  },
97
103
  storage,
98
104
  live_state,
99
105
  tracked_state,
100
106
  binary_cas,
101
- commit_store,
102
- version_ctx,
107
+ branch_ctx,
103
108
  catalog_context,
109
+ write_lock,
104
110
  ))
105
111
  }
106
112
 
107
113
  pub(super) fn new(
108
114
  mode: SessionMode,
109
- storage: StorageContext,
115
+ storage: StorageContext<B>,
110
116
  live_state: Arc<LiveStateContext>,
111
117
  tracked_state: Arc<TrackedStateContext>,
112
118
  binary_cas: Arc<BinaryCasContext>,
113
- commit_store: Arc<CommitStoreContext>,
114
- version_ctx: Arc<VersionContext>,
119
+ branch_ctx: Arc<BranchContext>,
115
120
  catalog_context: Arc<CatalogContext>,
121
+ write_lock: DurableWriteLock,
116
122
  ) -> Self {
117
- Self::new_with_closed(
123
+ Self::new_with_transaction_manager(
118
124
  mode,
119
125
  storage,
120
126
  live_state,
121
127
  tracked_state,
122
128
  binary_cas,
123
- commit_store,
124
- version_ctx,
129
+ branch_ctx,
125
130
  catalog_context,
126
- Arc::new(AtomicBool::new(false)),
131
+ write_lock,
132
+ SessionTransactionManager::new(),
127
133
  )
128
134
  }
129
135
 
130
- pub(super) fn new_with_closed(
136
+ pub(super) fn new_with_transaction_manager(
131
137
  mode: SessionMode,
132
- storage: StorageContext,
138
+ storage: StorageContext<B>,
133
139
  live_state: Arc<LiveStateContext>,
134
140
  tracked_state: Arc<TrackedStateContext>,
135
141
  binary_cas: Arc<BinaryCasContext>,
136
- commit_store: Arc<CommitStoreContext>,
137
- version_ctx: Arc<VersionContext>,
142
+ branch_ctx: Arc<BranchContext>,
138
143
  catalog_context: Arc<CatalogContext>,
139
- closed: Arc<AtomicBool>,
144
+ write_lock: DurableWriteLock,
145
+ transaction_manager: SessionTransactionManager,
140
146
  ) -> Self {
141
147
  Self {
142
148
  mode,
@@ -144,167 +150,239 @@ impl SessionContext {
144
150
  live_state,
145
151
  tracked_state,
146
152
  binary_cas,
147
- commit_store,
148
- version_ctx,
153
+ branch_ctx,
149
154
  catalog_context,
150
- closed,
155
+ write_lock,
156
+ transaction_manager,
151
157
  }
152
158
  }
153
159
 
154
160
  /// Releases this logical session handle. This is a lifecycle boundary only:
155
161
  /// successful writes are committed before their operation returns.
156
162
  pub async fn close(&self) -> Result<(), LixError> {
157
- self.closed.store(true, Ordering::SeqCst);
158
- Ok(())
163
+ self.transaction_manager.close().await
159
164
  }
160
165
 
161
166
  pub fn is_closed(&self) -> bool {
162
- self.closed.load(Ordering::SeqCst)
167
+ self.transaction_manager.is_closed()
168
+ }
169
+
170
+ #[cfg(test)]
171
+ pub(crate) fn operation_in_progress_count_for_test(&self) -> usize {
172
+ self.transaction_manager.operation_count_for_test()
173
+ }
174
+
175
+ #[cfg(test)]
176
+ pub(crate) fn commit_in_progress_for_test(&self) -> bool {
177
+ self.transaction_manager.commit_in_progress_for_test()
163
178
  }
164
179
 
165
- pub(crate) fn closed_flag(&self) -> Arc<AtomicBool> {
166
- Arc::clone(&self.closed)
180
+ #[cfg(test)]
181
+ pub(crate) fn active_transaction_for_test(&self) -> bool {
182
+ self.transaction_manager.active_transaction_for_test()
183
+ }
184
+
185
+ pub(super) fn transaction_manager(&self) -> SessionTransactionManager {
186
+ self.transaction_manager.clone()
167
187
  }
168
188
 
169
189
  pub(crate) fn ensure_open(&self) -> Result<(), LixError> {
170
- if self.is_closed() {
171
- return Err(closed_error());
172
- }
173
- Ok(())
190
+ self.transaction_manager.ensure_open()
191
+ }
192
+
193
+ pub(super) fn begin_session_operation(&self) -> Result<SessionOperationGuard, LixError> {
194
+ self.transaction_manager.begin_session_operation()
195
+ }
196
+
197
+ pub(super) fn begin_session_write_lease(&self) -> Result<SessionWriteLease, LixError> {
198
+ self.transaction_manager.begin_write_lease()
199
+ }
200
+
201
+ pub(super) fn begin_explicit_session_write_lease(&self) -> Result<SessionWriteLease, LixError> {
202
+ self.transaction_manager.begin_explicit_write_lease()
203
+ }
204
+
205
+ pub(super) async fn begin_session_write_access(&self) -> Result<SessionWriteAccess, LixError> {
206
+ let write_lease = self.begin_session_write_lease()?;
207
+ self.begin_session_write_access_with_lease(write_lease)
208
+ .await
209
+ }
210
+
211
+ pub(super) async fn begin_explicit_session_write_access(
212
+ &self,
213
+ ) -> Result<SessionWriteAccess, LixError> {
214
+ let write_lease = self.begin_explicit_session_write_lease()?;
215
+ self.begin_session_write_access_with_lease(write_lease)
216
+ .await
217
+ }
218
+
219
+ async fn begin_session_write_access_with_lease(
220
+ &self,
221
+ write_lease: SessionWriteLease,
222
+ ) -> Result<SessionWriteAccess, LixError> {
223
+ let write_guard = self.write_lock.lock_owned().await;
224
+ let write_access = SessionWriteAccess {
225
+ _write_lease: write_lease,
226
+ _write_guard: write_guard,
227
+ };
228
+ self.ensure_open()?;
229
+ Ok(write_access)
174
230
  }
175
231
 
176
- /// Resolves the version this session should operate on right now.
232
+ /// Resolves the branch this session should operate on right now.
177
233
  ///
178
- /// This is a read-path helper. Write flows must resolve the active version
234
+ /// This is a read-path helper. Write flows must resolve the active branch
179
235
  /// through the transaction capability so the read is scoped to the
180
236
  /// same backend transaction as the writes it influences.
181
237
  ///
182
- /// Pinned sessions are pure in-memory views over one version. Workspace
238
+ /// Pinned sessions are pure in-memory views over one branch. Workspace
183
239
  /// sessions read the shared workspace selector from untracked global
184
240
  /// `lix_key_value` state so multiple open app sessions can observe the same
185
- /// active workspace version.
186
- pub async fn active_version_id(&self) -> Result<String, LixError> {
187
- let mut transaction = self.storage.begin_read_transaction().await?;
188
- let result = self
189
- .active_version_id_from_reader(transaction.as_mut())
190
- .await;
241
+ /// active workspace branch.
242
+ pub async fn active_branch_id(&self) -> Result<String, LixError> {
243
+ let _operation_guard = self.begin_session_operation()?;
244
+ let transaction = self.storage.begin_read(StorageReadOptions::default())?;
245
+ let result = self.active_branch_id_from_reader(&transaction).await;
191
246
  match result {
192
- Ok(version_id) => {
193
- transaction.rollback().await?;
194
- Ok(version_id)
195
- }
196
- Err(error) => {
197
- let _ = transaction.rollback().await;
198
- Err(error)
199
- }
247
+ Ok(branch_id) => Ok(branch_id),
248
+ Err(error) => Err(error),
200
249
  }
201
250
  }
202
251
 
203
- pub(super) async fn active_version_id_from_reader<S>(
252
+ pub(super) async fn active_branch_id_from_reader<S>(
204
253
  &self,
205
- reader: &mut S,
254
+ reader: &S,
206
255
  ) -> Result<String, LixError>
207
256
  where
208
- S: StorageReader + ?Sized,
257
+ S: StorageRead + Send + Sync + ?Sized,
209
258
  {
210
259
  self.ensure_open()?;
211
260
  match &self.mode {
212
- SessionMode::Pinned { version_id } => Ok(version_id.clone()),
213
- SessionMode::Workspace => self.load_workspace_version_id(reader).await,
261
+ SessionMode::Pinned { branch_id } => Ok(branch_id.clone()),
262
+ SessionMode::Workspace => self.load_workspace_branch_id(reader).await,
214
263
  }
215
264
  }
216
265
 
217
- async fn load_workspace_version_id<S>(&self, reader: &mut S) -> Result<String, LixError>
266
+ async fn load_workspace_branch_id<S>(&self, reader: &S) -> Result<String, LixError>
218
267
  where
219
- S: StorageReader + ?Sized,
268
+ S: StorageRead + Send + Sync + ?Sized,
220
269
  {
221
270
  let row = self
222
271
  .live_state
223
- .reader(&mut *reader)
272
+ .reader(reader)
224
273
  .load_row(&LiveStateRowRequest {
225
274
  schema_key: "lix_key_value".to_string(),
226
- version_id: GLOBAL_VERSION_ID.to_string(),
227
- entity_id: EntityIdentity::single(WORKSPACE_VERSION_KEY),
275
+ branch_id: GLOBAL_BRANCH_ID.to_string(),
276
+ entity_pk: EntityPk::single(WORKSPACE_BRANCH_KEY),
228
277
  file_id: NullableKeyFilter::Null,
229
278
  })
230
279
  .await?
231
280
  .ok_or_else(|| {
232
281
  LixError::new(
233
282
  "LIX_ERROR_UNKNOWN",
234
- "workspace version selector is missing lix_key_value:lix_workspace_version_id",
283
+ "workspace branch selector is missing lix_key_value:lix_workspace_branch_id",
235
284
  )
236
285
  })?;
237
286
  let snapshot_content = row.snapshot_content.as_deref().ok_or_else(|| {
238
287
  LixError::new(
239
288
  "LIX_ERROR_UNKNOWN",
240
- "workspace version selector is missing snapshot_content",
289
+ "workspace branch selector is missing snapshot_content",
241
290
  )
242
291
  })?;
243
292
  let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
244
293
  LixError::new(
245
294
  "LIX_ERROR_UNKNOWN",
246
- format!("workspace version selector snapshot is invalid JSON: {error}"),
295
+ format!("workspace branch selector snapshot is invalid JSON: {error}"),
247
296
  )
248
297
  })?;
249
- let version_id = snapshot
298
+ let branch_id = snapshot
250
299
  .get("value")
251
300
  .and_then(JsonValue::as_str)
252
301
  .filter(|value| !value.is_empty())
253
302
  .ok_or_else(|| {
254
303
  LixError::new(
255
304
  "LIX_ERROR_UNKNOWN",
256
- "workspace version selector value must be a non-empty string",
305
+ "workspace branch selector value must be a non-empty string",
257
306
  )
258
307
  })?
259
308
  .to_string();
260
309
 
261
- let version_ref = self.version_ctx.ref_reader(&mut *reader);
262
- VersionLifecycle::new(&version_ref)
310
+ let branch_ref = self.branch_ctx.ref_reader(reader);
311
+ BranchLifecycle::new(&branch_ref)
263
312
  .require_existing_ref(
264
- &version_id,
265
- VersionOperation::LoadWorkspaceSelector,
266
- VersionReferenceRole::WorkspaceSelector,
313
+ &branch_id,
314
+ BranchOperation::LoadWorkspaceSelector,
315
+ BranchReferenceRole::WorkspaceSelector,
267
316
  )
268
317
  .await?;
269
318
 
270
- Ok(version_id)
319
+ Ok(branch_id)
271
320
  }
272
321
 
273
322
  pub(crate) async fn with_write_transaction<T, F>(&self, f: F) -> Result<T, LixError>
274
323
  where
275
324
  F: for<'tx> FnOnce(
276
- &'tx mut Transaction,
325
+ &'tx mut Transaction<B>,
277
326
  ) -> Pin<Box<dyn Future<Output = Result<T, LixError>> + 'tx>>,
278
327
  {
279
328
  self.ensure_open()?;
329
+ let write_access = self.begin_session_write_access().await?;
330
+ self.with_write_transaction_reserved(write_access, f).await
331
+ }
332
+
333
+ pub(super) async fn with_write_transaction_reserved<T, F>(
334
+ &self,
335
+ _write_access: SessionWriteAccess,
336
+ f: F,
337
+ ) -> Result<T, LixError>
338
+ where
339
+ F: for<'tx> FnOnce(
340
+ &'tx mut Transaction<B>,
341
+ ) -> Pin<Box<dyn Future<Output = Result<T, LixError>> + 'tx>>,
342
+ {
280
343
  let opened = open_transaction(
281
344
  &self.mode,
282
345
  self.storage.clone(),
283
346
  Arc::clone(&self.live_state),
284
347
  Arc::clone(&self.tracked_state),
285
348
  Arc::clone(&self.binary_cas),
286
- Arc::clone(&self.commit_store),
287
- Arc::clone(&self.version_ctx),
349
+ Arc::clone(&self.branch_ctx),
288
350
  Arc::clone(&self.catalog_context),
289
351
  )
290
352
  .await?;
353
+ self.ensure_open()?;
291
354
  let mut transaction = opened.transaction;
355
+ transaction.attach_commit_boundary(self.transaction_commit_boundary());
292
356
  let runtime_functions = opened.runtime_functions;
293
357
 
294
358
  match f(&mut transaction).await {
295
359
  Ok(value) => {
360
+ self.ensure_open()?;
296
361
  transaction.commit(&runtime_functions).await?;
297
362
  Ok(value)
298
363
  }
299
- Err(error) => {
300
- let _ = transaction.rollback().await;
301
- Err(error)
302
- }
364
+ Err(error) => Err(error),
303
365
  }
304
366
  }
367
+
368
+ #[cfg(test)]
369
+ pub(super) fn begin_commit(&self) -> crate::transaction::CommitBoundaryGuard {
370
+ self.transaction_manager.begin_commit()
371
+ }
372
+
373
+ pub(super) fn transaction_commit_boundary(
374
+ &self,
375
+ ) -> crate::transaction::TransactionCommitBoundary {
376
+ self.transaction_manager.transaction_commit_boundary()
377
+ }
378
+ }
379
+
380
+ pub(super) struct SessionWriteAccess {
381
+ _write_guard: DurableWriteGuard,
382
+ _write_lease: SessionWriteLease,
305
383
  }
306
384
 
307
- fn closed_error() -> LixError {
385
+ pub(super) fn closed_error() -> LixError {
308
386
  LixError::new(LixError::CODE_CLOSED, "Lix handle is closed")
309
387
  .with_hint("Open a new Lix handle before calling this method.")
310
388
  }
@@ -313,32 +391,40 @@ fn closed_error() -> LixError {
313
391
  ///
314
392
  /// Write statements re-plan against `Transaction`; this context intentionally
315
393
  /// has no write stager.
316
- pub(super) struct SessionSqlExecutionContext<'a> {
317
- pub(super) active_version_id: &'a str,
318
- pub(super) read_store:
319
- ScopedStorageReader<Box<dyn StorageReadTransaction + Send + Sync + 'static>>,
394
+ pub(super) struct SessionSqlExecutionContext<'a, R> {
395
+ pub(super) active_branch_id: &'a str,
396
+ pub(super) read_store: StorageReadScope<R>,
320
397
  pub(super) live_state: Arc<LiveStateContext>,
321
398
  pub(super) binary_cas: Arc<BinaryCasContext>,
322
- pub(super) commit_store: Arc<CommitStoreContext>,
323
- pub(super) version_ctx: Arc<VersionContext>,
399
+ pub(super) branch_ctx: Arc<BranchContext>,
324
400
  pub(super) visible_schemas: Vec<JsonValue>,
325
401
  pub(super) functions: FunctionProviderHandle,
326
402
  }
327
403
 
328
- impl SqlExecutionContext for SessionSqlExecutionContext<'_> {
329
- fn active_version_id(&self) -> &str {
330
- self.active_version_id
404
+ impl<R> SqlExecutionContext for SessionSqlExecutionContext<'_, R>
405
+ where
406
+ R: crate::storage::StorageBackendRead + Clone + Send + Sync + 'static,
407
+ {
408
+ type ReadStore = StorageReadScope<R>;
409
+
410
+ fn active_branch_id(&self) -> &str {
411
+ self.active_branch_id
331
412
  }
332
413
 
333
414
  fn live_state(&self) -> Arc<dyn LiveStateReader> {
334
415
  Arc::new(self.live_state.reader(self.read_store.clone())) as Arc<dyn LiveStateReader>
335
416
  }
336
417
 
337
- fn commit_store_query_source(&self) -> SqlCommitStoreQuerySource {
338
- let read_scope = StorageReadScope::new(self.read_store.clone());
339
- CommitStoreQuerySource {
340
- commit_store_reader: Arc::new(self.commit_store.reader(read_scope.store())),
341
- json_reader: JsonStoreContext::new().reader(read_scope.store()),
418
+ fn history_query_source(&self) -> SqlHistoryQuerySource<Self::ReadStore> {
419
+ HistoryQuerySource {
420
+ json_reader: JsonStoreContext::new().reader(self.read_store.store()),
421
+ }
422
+ }
423
+
424
+ fn changelog_query_source(&self) -> SqlChangelogQuerySource<Self::ReadStore> {
425
+ ChangelogQuerySource {
426
+ store: self.read_store.clone(),
427
+ json_reader: JsonStoreContext::new().reader(self.read_store.store()),
342
428
  }
343
429
  }
344
430
 
@@ -346,8 +432,8 @@ impl SqlExecutionContext for SessionSqlExecutionContext<'_> {
346
432
  Box::new(CommitGraphContext::new().reader(self.read_store.clone()))
347
433
  }
348
434
 
349
- fn version_ref(&self) -> Arc<dyn VersionRefReader> {
350
- Arc::new(self.version_ctx.ref_reader(self.read_store.clone()))
435
+ fn branch_ref(&self) -> Arc<dyn BranchRefReader> {
436
+ Arc::new(self.branch_ctx.ref_reader(self.read_store.clone()))
351
437
  }
352
438
 
353
439
  fn functions(&self) -> FunctionProviderHandle {
@@ -362,3 +448,612 @@ impl SqlExecutionContext for SessionSqlExecutionContext<'_> {
362
448
  Ok(self.visible_schemas.clone())
363
449
  }
364
450
  }
451
+
452
+ #[cfg(test)]
453
+ mod tests {
454
+ use std::future::Future;
455
+ use std::pin::Pin;
456
+ use std::sync::Condvar;
457
+ use std::sync::Mutex;
458
+ use std::task::{Context, Poll};
459
+ use std::thread;
460
+ use std::time::{Duration, Instant};
461
+
462
+ use crate::backend::{
463
+ Backend, BackendCapabilities, BackendError, DurableWriteLock, InMemoryBackend,
464
+ InMemoryRead, InMemoryWrite, ReadOptions, WriteOptions,
465
+ };
466
+ use crate::Engine;
467
+ use futures_util::task::noop_waker_ref;
468
+
469
+ const TEST_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
470
+
471
+ fn wait_until(description: &str, mut condition: impl FnMut() -> bool) {
472
+ let deadline = Instant::now() + TEST_WAIT_TIMEOUT;
473
+ while !condition() {
474
+ assert!(
475
+ Instant::now() < deadline,
476
+ "timed out waiting for {description}"
477
+ );
478
+ thread::yield_now();
479
+ }
480
+ }
481
+
482
+ fn assert_close_pending<F>(mut future: Pin<&mut F>)
483
+ where
484
+ F: Future<Output = Result<(), crate::LixError>>,
485
+ {
486
+ let mut cx = Context::from_waker(noop_waker_ref());
487
+ assert!(
488
+ matches!(future.as_mut().poll(&mut cx), Poll::Pending),
489
+ "close should remain pending while guarded work is in progress"
490
+ );
491
+ }
492
+
493
+ async fn assert_close_finishes<F>(future: Pin<&mut F>, description: &str)
494
+ where
495
+ F: Future<Output = Result<(), crate::LixError>>,
496
+ {
497
+ tokio::time::timeout(TEST_WAIT_TIMEOUT, future)
498
+ .await
499
+ .unwrap_or_else(|_| panic!("timed out waiting for {description}"))
500
+ .unwrap_or_else(|error| panic!("{description} failed: {error:?}"));
501
+ }
502
+
503
+ fn join_thread<T>(handle: thread::JoinHandle<T>, description: &str) -> T {
504
+ wait_until(description, || handle.is_finished());
505
+ match handle.join() {
506
+ Ok(result) => result,
507
+ Err(_) => panic!("{description} panicked"),
508
+ }
509
+ }
510
+
511
+ async fn open_session() -> std::sync::Arc<super::SessionContext<InMemoryBackend>> {
512
+ let backend = InMemoryBackend::default();
513
+ let _receipt = Engine::initialize(backend.clone())
514
+ .await
515
+ .expect("backend should initialize");
516
+ let engine = Engine::new(backend)
517
+ .await
518
+ .expect("initialized backend should create engine");
519
+ std::sync::Arc::new(
520
+ engine
521
+ .open_workspace_session()
522
+ .await
523
+ .expect("workspace session should open"),
524
+ )
525
+ }
526
+
527
+ async fn open_blocking_read_session() -> (
528
+ std::sync::Arc<super::SessionContext<BlockingBeginReadBackend>>,
529
+ BlockingGate,
530
+ ) {
531
+ let backend = BlockingBeginReadBackend::new();
532
+ let gate = backend.gate();
533
+ let _receipt = Engine::initialize(backend.clone())
534
+ .await
535
+ .expect("backend should initialize");
536
+ let engine = Engine::new(backend)
537
+ .await
538
+ .expect("initialized backend should create engine");
539
+ (
540
+ std::sync::Arc::new(
541
+ engine
542
+ .open_workspace_session()
543
+ .await
544
+ .expect("workspace session should open"),
545
+ ),
546
+ gate,
547
+ )
548
+ }
549
+
550
+ async fn open_blocking_write_session() -> (
551
+ std::sync::Arc<super::SessionContext<BlockingBeginWriteBackend>>,
552
+ BlockingGate,
553
+ ) {
554
+ let backend = BlockingBeginWriteBackend::new();
555
+ let gate = backend.gate();
556
+ let _receipt = Engine::initialize(backend.clone())
557
+ .await
558
+ .expect("backend should initialize");
559
+ let engine = Engine::new(backend)
560
+ .await
561
+ .expect("initialized backend should create engine");
562
+ (
563
+ std::sync::Arc::new(
564
+ engine
565
+ .open_workspace_session()
566
+ .await
567
+ .expect("workspace session should open"),
568
+ ),
569
+ gate,
570
+ )
571
+ }
572
+
573
+ #[tokio::test]
574
+ async fn close_waits_for_session_operation_guard_to_drop() {
575
+ let session = open_session().await;
576
+ let guard = session
577
+ .begin_session_operation()
578
+ .expect("session operation should begin");
579
+ let mut close = Box::pin(session.close());
580
+ assert_close_pending(close.as_mut());
581
+
582
+ drop(guard);
583
+ assert_close_finishes(close.as_mut(), "close after operation guard drops").await;
584
+ }
585
+
586
+ #[tokio::test]
587
+ async fn close_waits_for_commit_guard_to_drop() {
588
+ let session = open_session().await;
589
+ let guard = session.begin_commit();
590
+ let mut close = Box::pin(session.close());
591
+ assert_close_pending(close.as_mut());
592
+
593
+ drop(guard);
594
+ assert_close_finishes(close.as_mut(), "close after commit guard drops").await;
595
+ }
596
+
597
+ #[tokio::test]
598
+ async fn session_read_execute_holds_operation_guard() {
599
+ let session = open_session().await;
600
+ let result = session
601
+ .execute("SELECT 1", &[])
602
+ .await
603
+ .expect("read should succeed");
604
+ assert_eq!(result.len(), 1);
605
+ assert_eq!(session.operation_in_progress_count_for_test(), 0);
606
+ }
607
+
608
+ #[tokio::test]
609
+ async fn active_transaction_read_execute_holds_operation_guard() {
610
+ let session = open_session().await;
611
+ let mut transaction = session
612
+ .begin_transaction()
613
+ .await
614
+ .expect("transaction should begin");
615
+ assert!(session.active_transaction_for_test());
616
+ let result = transaction
617
+ .execute("SELECT 1", &[])
618
+ .await
619
+ .expect("transaction read should succeed");
620
+ assert_eq!(result.len(), 1);
621
+ assert_eq!(session.operation_in_progress_count_for_test(), 1);
622
+ assert!(session.active_transaction_for_test());
623
+ transaction
624
+ .rollback()
625
+ .await
626
+ .expect("transaction rollback should succeed");
627
+ assert_eq!(session.operation_in_progress_count_for_test(), 0);
628
+ assert!(!session.active_transaction_for_test());
629
+ }
630
+
631
+ #[tokio::test]
632
+ async fn close_rejects_idle_explicit_transaction_without_waiting() {
633
+ let session = open_session().await;
634
+ let transaction = session
635
+ .begin_transaction()
636
+ .await
637
+ .expect("transaction should begin");
638
+
639
+ let error = session
640
+ .close()
641
+ .await
642
+ .expect_err("close should reject an idle explicit transaction");
643
+ assert_eq!(error.code, "LIX_INVALID_TRANSACTION_STATE");
644
+
645
+ transaction
646
+ .rollback()
647
+ .await
648
+ .expect("rollback should remain available after rejected close");
649
+ }
650
+
651
+ #[tokio::test]
652
+ async fn transaction_open_waits_for_write_lock() {
653
+ let session = open_session().await;
654
+ let write_guard = session.write_lock.lock_owned().await;
655
+
656
+ let opener_session = std::sync::Arc::clone(&session);
657
+ let opener = thread::spawn(move || {
658
+ let runtime = tokio::runtime::Builder::new_current_thread()
659
+ .build()
660
+ .expect("test runtime should build");
661
+ runtime.block_on(async move { opener_session.begin_transaction().await })
662
+ });
663
+ wait_until("explicit transaction open to reserve the session", || {
664
+ session.operation_in_progress_count_for_test() > 0
665
+ && session.active_transaction_for_test()
666
+ && !opener.is_finished()
667
+ });
668
+
669
+ assert!(
670
+ !opener.is_finished(),
671
+ "transaction open should wait for the write lock"
672
+ );
673
+ assert!(session.active_transaction_for_test());
674
+
675
+ drop(write_guard);
676
+ let transaction = join_thread(opener, "queued transaction opener")
677
+ .expect("transaction should begin after write lock is released");
678
+ transaction
679
+ .rollback()
680
+ .await
681
+ .expect("transaction rollback should succeed");
682
+ }
683
+
684
+ #[tokio::test]
685
+ async fn close_waits_for_session_write_queued_on_write_lock() {
686
+ let session = open_session().await;
687
+ let write_guard = session.write_lock.lock_owned().await;
688
+
689
+ let writer_session = std::sync::Arc::clone(&session);
690
+ let writer = thread::spawn(move || {
691
+ let runtime = tokio::runtime::Builder::new_current_thread()
692
+ .build()
693
+ .expect("test runtime should build");
694
+ runtime.block_on(async move {
695
+ writer_session
696
+ .execute(
697
+ "INSERT INTO lix_key_value (key, value) VALUES ('queued-write-close', 'value')",
698
+ &[],
699
+ )
700
+ .await
701
+ })
702
+ });
703
+ wait_until("queued session write to reserve the session", || {
704
+ session.operation_in_progress_count_for_test() > 0
705
+ && session.active_transaction_for_test()
706
+ });
707
+
708
+ let mut close = Box::pin(session.close());
709
+ assert_close_pending(close.as_mut());
710
+
711
+ drop(write_guard);
712
+ let write_error =
713
+ join_thread(writer, "queued writer").expect_err("queued write should observe close");
714
+ assert_eq!(write_error.code, crate::LixError::CODE_CLOSED);
715
+ assert_close_finishes(close.as_mut(), "close after queued write exits").await;
716
+ }
717
+
718
+ #[tokio::test]
719
+ async fn session_read_does_not_wait_for_write_lock() {
720
+ let session = open_session().await;
721
+ let write_guard = session.write_lock.lock_owned().await;
722
+
723
+ let result = tokio::time::timeout(TEST_WAIT_TIMEOUT, session.execute("SELECT 1", &[]))
724
+ .await
725
+ .expect("read should not wait for the write lock")
726
+ .expect("read should succeed");
727
+
728
+ assert_eq!(result.len(), 1);
729
+ drop(write_guard);
730
+ }
731
+
732
+ #[tokio::test]
733
+ async fn explicit_transaction_commit_sets_commit_guard() {
734
+ let session = open_session().await;
735
+ let mut transaction = session
736
+ .begin_transaction()
737
+ .await
738
+ .expect("transaction should begin");
739
+ transaction
740
+ .execute(
741
+ "INSERT INTO lix_key_value (key, value) VALUES ('commit-guard-test', 'value')",
742
+ &[],
743
+ )
744
+ .await
745
+ .expect("transaction write should stage");
746
+ transaction
747
+ .commit()
748
+ .await
749
+ .expect("transaction commit should succeed");
750
+ assert!(!session.commit_in_progress_for_test());
751
+ }
752
+
753
+ #[tokio::test]
754
+ async fn close_waits_for_explicit_transaction_open_queued_on_write_lock() {
755
+ let session = open_session().await;
756
+ let write_guard = session.write_lock.lock_owned().await;
757
+
758
+ let opener_session = std::sync::Arc::clone(&session);
759
+ let opener = thread::spawn(move || {
760
+ let runtime = tokio::runtime::Builder::new_current_thread()
761
+ .build()
762
+ .expect("test runtime should build");
763
+ runtime.block_on(async move { opener_session.begin_transaction().await })
764
+ });
765
+ wait_until("explicit transaction open to queue on write lock", || {
766
+ session.operation_in_progress_count_for_test() > 0
767
+ && session.active_transaction_for_test()
768
+ && !opener.is_finished()
769
+ });
770
+ assert!(
771
+ !opener.is_finished(),
772
+ "transaction open should still be queued on write lock"
773
+ );
774
+
775
+ let mut close = Box::pin(session.close());
776
+ assert_close_pending(close.as_mut());
777
+
778
+ drop(write_guard);
779
+ let open_error = match join_thread(opener, "queued explicit transaction opener") {
780
+ Ok(_) => panic!("queued explicit transaction open should observe close"),
781
+ Err(error) => error,
782
+ };
783
+ assert_eq!(open_error.code, crate::LixError::CODE_CLOSED);
784
+ assert_close_finishes(close.as_mut(), "close after queued explicit open exits").await;
785
+ }
786
+
787
+ #[tokio::test]
788
+ async fn close_waits_for_session_read_blocked_in_backend_read() {
789
+ let (session, gate) = open_blocking_read_session().await;
790
+
791
+ gate.block_next();
792
+ let reader_session = std::sync::Arc::clone(&session);
793
+ let reader = std::thread::spawn(move || {
794
+ let runtime = tokio::runtime::Builder::new_current_thread()
795
+ .build()
796
+ .expect("test runtime should build");
797
+ runtime.block_on(async move { reader_session.execute("SELECT 1", &[]).await })
798
+ });
799
+ gate.wait_until_blocked();
800
+
801
+ let mut close = Box::pin(session.close());
802
+ assert_close_pending(close.as_mut());
803
+
804
+ gate.release();
805
+ let error = join_thread(reader, "blocked reader")
806
+ .expect_err("read should observe close after backend read resumes");
807
+ assert_eq!(error.code, crate::LixError::CODE_CLOSED);
808
+ assert_close_finishes(close.as_mut(), "close after blocked read exits").await;
809
+ }
810
+
811
+ #[tokio::test]
812
+ async fn close_rejects_active_transaction_read_blocked_in_backend_read() {
813
+ let (session, gate) = open_blocking_read_session().await;
814
+ let mut transaction = session
815
+ .begin_transaction()
816
+ .await
817
+ .expect("transaction should begin");
818
+
819
+ gate.block_next();
820
+ let reader = std::thread::spawn(move || {
821
+ let runtime = tokio::runtime::Builder::new_current_thread()
822
+ .build()
823
+ .expect("test runtime should build");
824
+ runtime.block_on(async move { transaction.execute("SELECT 1", &[]).await })
825
+ });
826
+ gate.wait_until_blocked();
827
+
828
+ let close_error = session
829
+ .close()
830
+ .await
831
+ .expect_err("close should reject an active explicit transaction read");
832
+ assert_eq!(close_error.code, "LIX_INVALID_TRANSACTION_STATE");
833
+
834
+ gate.release();
835
+ let result = join_thread(reader, "blocked transaction reader")
836
+ .expect("in-flight transaction read should finish after rejected close");
837
+ assert_eq!(result.len(), 1);
838
+ }
839
+
840
+ #[tokio::test]
841
+ async fn close_waits_for_explicit_transaction_blocked_in_backend_commit() {
842
+ let (session, gate) = open_blocking_write_session().await;
843
+ let mut transaction = session
844
+ .begin_transaction()
845
+ .await
846
+ .expect("transaction should begin");
847
+ transaction
848
+ .execute(
849
+ "INSERT INTO lix_key_value (key, value) VALUES ('blocked-commit', 'value')",
850
+ &[],
851
+ )
852
+ .await
853
+ .expect("transaction write should stage");
854
+
855
+ gate.block_next();
856
+ let committer = std::thread::spawn(move || {
857
+ let runtime = tokio::runtime::Builder::new_current_thread()
858
+ .build()
859
+ .expect("test runtime should build");
860
+ runtime.block_on(async move { transaction.commit().await })
861
+ });
862
+ gate.wait_until_blocked();
863
+ assert!(
864
+ session.commit_in_progress_for_test(),
865
+ "blocked explicit transaction commit should set the commit guard"
866
+ );
867
+
868
+ let mut close = Box::pin(session.close());
869
+ assert_close_pending(close.as_mut());
870
+
871
+ gate.release();
872
+ join_thread(committer, "blocked committer")
873
+ .expect("commit already at durable boundary should finish");
874
+ assert_close_finishes(close.as_mut(), "close after commit exits").await;
875
+ assert!(
876
+ !session.commit_in_progress_for_test(),
877
+ "commit guard should clear after the blocked commit exits"
878
+ );
879
+ }
880
+
881
+ #[derive(Clone)]
882
+ struct BlockingBeginReadBackend {
883
+ inner: InMemoryBackend,
884
+ gate: BlockingGate,
885
+ }
886
+
887
+ impl BlockingBeginReadBackend {
888
+ fn new() -> Self {
889
+ Self {
890
+ inner: InMemoryBackend::default(),
891
+ gate: BlockingGate::new(),
892
+ }
893
+ }
894
+
895
+ fn gate(&self) -> BlockingGate {
896
+ self.gate.clone()
897
+ }
898
+ }
899
+
900
+ impl Backend for BlockingBeginReadBackend {
901
+ type Read<'a>
902
+ = InMemoryRead
903
+ where
904
+ Self: 'a;
905
+
906
+ type Write<'a>
907
+ = InMemoryWrite
908
+ where
909
+ Self: 'a;
910
+
911
+ fn capabilities(&self) -> BackendCapabilities {
912
+ self.inner.capabilities()
913
+ }
914
+
915
+ fn begin_read(&self, opts: ReadOptions) -> Result<Self::Read<'_>, BackendError> {
916
+ self.gate.maybe_block();
917
+ self.inner.begin_read(opts)
918
+ }
919
+
920
+ fn begin_write(&self, opts: WriteOptions) -> Result<Self::Write<'_>, BackendError> {
921
+ self.inner.begin_write(opts)
922
+ }
923
+
924
+ fn durable_write_lock(&self) -> DurableWriteLock {
925
+ self.inner.durable_write_lock()
926
+ }
927
+ }
928
+
929
+ #[derive(Clone)]
930
+ struct BlockingBeginWriteBackend {
931
+ inner: InMemoryBackend,
932
+ gate: BlockingGate,
933
+ }
934
+
935
+ impl BlockingBeginWriteBackend {
936
+ fn new() -> Self {
937
+ Self {
938
+ inner: InMemoryBackend::default(),
939
+ gate: BlockingGate::new(),
940
+ }
941
+ }
942
+
943
+ fn gate(&self) -> BlockingGate {
944
+ self.gate.clone()
945
+ }
946
+ }
947
+
948
+ impl Backend for BlockingBeginWriteBackend {
949
+ type Read<'a>
950
+ = InMemoryRead
951
+ where
952
+ Self: 'a;
953
+
954
+ type Write<'a>
955
+ = InMemoryWrite
956
+ where
957
+ Self: 'a;
958
+
959
+ fn capabilities(&self) -> BackendCapabilities {
960
+ self.inner.capabilities()
961
+ }
962
+
963
+ fn begin_read(&self, opts: ReadOptions) -> Result<Self::Read<'_>, BackendError> {
964
+ self.inner.begin_read(opts)
965
+ }
966
+
967
+ fn begin_write(&self, opts: WriteOptions) -> Result<Self::Write<'_>, BackendError> {
968
+ self.gate.maybe_block();
969
+ self.inner.begin_write(opts)
970
+ }
971
+
972
+ fn durable_write_lock(&self) -> DurableWriteLock {
973
+ self.inner.durable_write_lock()
974
+ }
975
+ }
976
+
977
+ #[derive(Clone)]
978
+ struct BlockingGate {
979
+ state: std::sync::Arc<(Mutex<BlockingGateState>, Condvar)>,
980
+ }
981
+
982
+ impl BlockingGate {
983
+ fn new() -> Self {
984
+ Self {
985
+ state: std::sync::Arc::new((
986
+ Mutex::new(BlockingGateState::default()),
987
+ Condvar::new(),
988
+ )),
989
+ }
990
+ }
991
+
992
+ fn block_next(&self) {
993
+ let (lock, _) = &*self.state;
994
+ let mut state = lock.lock().expect("blocking gate lock should not poison");
995
+ state.block_next = true;
996
+ state.blocked = false;
997
+ state.released = false;
998
+ }
999
+
1000
+ fn maybe_block(&self) {
1001
+ let (lock, condvar) = &*self.state;
1002
+ let mut state = lock.lock().expect("blocking gate lock should not poison");
1003
+ if !state.block_next {
1004
+ return;
1005
+ }
1006
+ state.block_next = false;
1007
+ state.blocked = true;
1008
+ condvar.notify_all();
1009
+ let deadline = Instant::now() + TEST_WAIT_TIMEOUT;
1010
+ while !state.released {
1011
+ let remaining = deadline.saturating_duration_since(Instant::now());
1012
+ assert!(
1013
+ !remaining.is_zero(),
1014
+ "timed out waiting for blocking gate release"
1015
+ );
1016
+ let (next_state, wait_result) = condvar
1017
+ .wait_timeout(state, remaining)
1018
+ .expect("blocking gate lock should not poison after wait");
1019
+ state = next_state;
1020
+ assert!(
1021
+ !wait_result.timed_out() || state.released,
1022
+ "timed out waiting for blocking gate release"
1023
+ );
1024
+ }
1025
+ }
1026
+
1027
+ fn wait_until_blocked(&self) {
1028
+ let (lock, condvar) = &*self.state;
1029
+ let mut state = lock.lock().expect("blocking gate lock should not poison");
1030
+ let deadline = Instant::now() + TEST_WAIT_TIMEOUT;
1031
+ while !state.blocked {
1032
+ let remaining = deadline.saturating_duration_since(Instant::now());
1033
+ assert!(!remaining.is_zero(), "timed out waiting for blocking gate");
1034
+ let (next_state, wait_result) = condvar
1035
+ .wait_timeout(state, remaining)
1036
+ .expect("blocking gate lock should not poison after wait");
1037
+ state = next_state;
1038
+ assert!(
1039
+ !wait_result.timed_out() || state.blocked,
1040
+ "timed out waiting for blocking gate"
1041
+ );
1042
+ }
1043
+ }
1044
+
1045
+ fn release(&self) {
1046
+ let (lock, condvar) = &*self.state;
1047
+ let mut state = lock.lock().expect("blocking gate lock should not poison");
1048
+ state.released = true;
1049
+ condvar.notify_all();
1050
+ }
1051
+ }
1052
+
1053
+ #[derive(Default)]
1054
+ struct BlockingGateState {
1055
+ block_next: bool,
1056
+ blocked: bool,
1057
+ released: bool,
1058
+ }
1059
+ }