Skip to content

Commit e785a05

Browse files
emmaling27Convex, Inc.
authored and
Convex, Inc.
committed
Unmount and remount connects to the same component data (#28828)
This PR implements some of the desired component unmount + remount behavior. * Adds a new `ComponentState` to the metadata, unmounting instead of deleting the component when you push a configuration without an existing component. * Remounts components at the same path to the same data. * Adds an application-level test that unmounts + remounts by pushing a `mounted` project, then an `empty` project, then the `mounted` project again. This does not make the data read-only while unmounted, will follow up about that. GitOrigin-RevId: 9fe3753af05308066a0cb385d6357d3bb1a828a1
1 parent 7d99d72 commit e785a05

File tree

27 files changed

+1209
-17
lines changed

27 files changed

+1209
-17
lines changed

crates/application/src/tests/components.rs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use common::{
2+
bootstrap_model::components::ComponentState,
23
components::{
34
CanonicalizedComponentFunctionPath,
45
ComponentId,
@@ -12,6 +13,7 @@ use common::{
1213
RequestId,
1314
};
1415
use database::{
16+
BootstrapComponentsModel,
1517
TableModel,
1618
UserFacingModel,
1719
};
@@ -44,12 +46,21 @@ async fn run_function(
4446
application: &Application<TestRuntime>,
4547
udf_path: CanonicalizedUdfPath,
4648
args: Vec<JsonValue>,
49+
) -> anyhow::Result<Result<FunctionReturn, FunctionError>> {
50+
run_component_function(application, udf_path, args, ComponentPath::root()).await
51+
}
52+
53+
async fn run_component_function(
54+
application: &Application<TestRuntime>,
55+
udf_path: CanonicalizedUdfPath,
56+
args: Vec<JsonValue>,
57+
component: ComponentPath,
4758
) -> anyhow::Result<Result<FunctionReturn, FunctionError>> {
4859
application
4960
.any_udf(
5061
RequestId::new(),
5162
CanonicalizedComponentFunctionPath {
52-
component: ComponentPath::root(),
63+
component,
5364
udf_path,
5465
},
5566
args,
@@ -246,3 +257,76 @@ async fn test_delete_tables_in_component(rt: TestRuntime) -> anyhow::Result<()>
246257
assert!(!table_model.table_exists(table_namespace, &table_name));
247258
Ok(())
248259
}
260+
261+
#[convex_macro::test_runtime]
262+
async fn test_unmount_and_remount_component(rt: TestRuntime) -> anyhow::Result<()> {
263+
let application = Application::new_for_tests(&rt).await?;
264+
application.load_component_tests_modules("mounted").await?;
265+
let component_path = ComponentPath::deserialize(Some("component"))?;
266+
run_component_function(
267+
&application,
268+
"messages:insertMessage".parse()?,
269+
vec![assert_obj!("channel" => "sports", "text" => "the celtics won!").into()],
270+
component_path.clone(),
271+
)
272+
.await??;
273+
274+
// Unmount component
275+
application.load_component_tests_modules("empty").await?;
276+
277+
let mut tx = application.begin(Identity::system()).await?;
278+
let mut components_model = BootstrapComponentsModel::new(&mut tx);
279+
let (_, component_id) = components_model
280+
.component_path_to_ids(component_path.clone())
281+
.await?;
282+
let component = components_model
283+
.load_component(component_id)
284+
.await?
285+
.unwrap();
286+
assert!(matches!(component.state, ComponentState::Unmounted));
287+
288+
// Data should still exist in unmounted component tables
289+
let mut table_model = TableModel::new(&mut tx);
290+
let count = table_model
291+
.count(component_id.into(), &"messages".parse()?)
292+
.await?;
293+
assert_eq!(count, 1);
294+
295+
// Calling component function after the component is unmounted should fail
296+
let result = run_component_function(
297+
&application,
298+
"messages:listMessages".parse()?,
299+
vec![assert_obj!().into()],
300+
ComponentPath::deserialize(Some("component"))?,
301+
)
302+
.await?;
303+
assert!(result.is_err());
304+
305+
// Remount the component
306+
application.load_component_tests_modules("mounted").await?;
307+
308+
let mut tx = application.begin(Identity::system()).await?;
309+
let mut components_model = BootstrapComponentsModel::new(&mut tx);
310+
let component = components_model
311+
.load_component(component_id)
312+
.await?
313+
.unwrap();
314+
assert!(matches!(component.state, ComponentState::Active));
315+
316+
// Data should still exist in remounted component tables
317+
let mut table_model = TableModel::new(&mut tx);
318+
let count = table_model
319+
.count(component_id.into(), &"messages".parse()?)
320+
.await?;
321+
assert_eq!(count, 1);
322+
323+
// Calling functions from the remounted component should work
324+
run_component_function(
325+
&application,
326+
"messages:listMessages".parse()?,
327+
vec![assert_obj!().into()],
328+
ComponentPath::deserialize(Some("component"))?,
329+
)
330+
.await??;
331+
Ok(())
332+
}

crates/common/src/bootstrap_model/components/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ use crate::components::{
2424
pub struct ComponentMetadata {
2525
pub definition_id: DeveloperDocumentId,
2626
pub component_type: ComponentType,
27+
pub state: ComponentState,
28+
}
29+
30+
#[derive(Debug, Clone, Eq, PartialEq)]
31+
#[cfg_attr(any(test, feature = "testing"), derive(proptest_derive::Arbitrary))]
32+
pub enum ComponentState {
33+
/// The component is mounted and can be used.
34+
Active,
35+
/// The component is unmounted. Component functions are not available, and
36+
/// tables in the component are read-only.
37+
Unmounted,
2738
}
2839

2940
impl ComponentMetadata {
@@ -59,6 +70,7 @@ struct SerializedComponentMetadata {
5970
pub parent: Option<String>,
6071
pub name: Option<String>,
6172
pub args: Option<Vec<(String, SerializedResource)>>,
73+
pub state: Option<String>,
6274
}
6375

6476
impl TryFrom<ComponentMetadata> for SerializedComponentMetadata {
@@ -77,11 +89,16 @@ impl TryFrom<ComponentMetadata> for SerializedComponentMetadata {
7789
),
7890
),
7991
};
92+
let state = match m.state {
93+
ComponentState::Active => "active",
94+
ComponentState::Unmounted => "unmounted",
95+
};
8096
Ok(Self {
8197
definition_id: m.definition_id.to_string(),
8298
parent,
8399
name,
84100
args,
101+
state: Some(state.to_string()),
85102
})
86103
}
87104
}
@@ -102,9 +119,15 @@ impl TryFrom<SerializedComponentMetadata> for ComponentMetadata {
102119
},
103120
_ => anyhow::bail!("Invalid component type"),
104121
};
122+
let state = match m.state.as_deref() {
123+
None | Some("active") => ComponentState::Active,
124+
Some("unmounted") => ComponentState::Unmounted,
125+
Some(invalid_state) => anyhow::bail!("Invalid component state: {invalid_state}"),
126+
};
105127
Ok(Self {
106128
definition_id: m.definition_id.parse()?,
107129
component_type,
130+
state,
108131
})
109132
}
110133
}

crates/database/src/bootstrap_model/components/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ mod tests {
416416
ComponentInstantiation,
417417
},
418418
ComponentMetadata,
419+
ComponentState,
419420
ComponentType,
420421
},
421422
components::{
@@ -481,6 +482,7 @@ mod tests {
481482
ComponentMetadata {
482483
definition_id: root_definition_id.into(),
483484
component_type: ComponentType::App,
485+
state: ComponentState::Active,
484486
}
485487
.try_into()?,
486488
)
@@ -495,6 +497,7 @@ mod tests {
495497
name: "subcomponent_child".parse()?,
496498
args: Default::default(),
497499
},
500+
state: ComponentState::Active,
498501
}
499502
.try_into()?,
500503
)

crates/isolate/build.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const COMPONENT_TESTS_DIR: &str = "../../npm-packages/component-tests";
3030
const COMPONENT_TESTS_CHILD_DIR_EXCEPTIONS: [&str; 3] = [".rush", "node_modules", "projects"];
3131
/// Directory where test projects that use components live.
3232
const COMPONENT_TESTS_PROJECTS_DIR: &str = "../../npm-packages/component-tests/projects";
33-
const COMPONENT_TESTS_PROJECTS: [&str; 2] = ["basic", "with-schema"];
33+
const COMPONENT_TESTS_PROJECTS: [&str; 4] = ["basic", "with-schema", "mounted", "empty"];
3434
/// Components in `component-tests` directory that are used in projects.
3535
const COMPONENTS: [&str; 3] = ["component", "envVars", "errors"];
3636

crates/model/src/components/config.rs

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use common::{
66
components::{
77
definition::ComponentDefinitionMetadata,
88
ComponentMetadata,
9+
ComponentState,
910
ComponentType,
1011
},
1112
schema::SchemaState,
@@ -402,8 +403,8 @@ impl<'a, RT: Runtime> ComponentConfigModel<'a, RT> {
402403
let mut stack = vec![(ComponentPath::root(), None, existing_root, Some(app))];
403404
let mut diffs = BTreeMap::new();
404405
while let Some((path, parent_and_name, existing_node, new_node)) = stack.pop() {
405-
let new_metadata = match new_node {
406-
Some(new_node) => {
406+
let new_metadata = new_node
407+
.map(|new_node| {
407408
let definition_id = *definition_id_by_path
408409
.get(&new_node.definition_path)
409410
.context("Missing definition ID for component")?;
@@ -418,13 +419,13 @@ impl<'a, RT: Runtime> ComponentConfigModel<'a, RT> {
418419
args: new_node.args.clone(),
419420
},
420421
};
421-
Some(ComponentMetadata {
422+
Ok(ComponentMetadata {
422423
definition_id,
423424
component_type,
425+
state: ComponentState::Active,
424426
})
425-
},
426-
None => None,
427-
};
427+
})
428+
.transpose()?;
428429

429430
// Diff the node itself.
430431
let (internal_id, diff) = match (existing_node, new_metadata) {
@@ -452,8 +453,8 @@ impl<'a, RT: Runtime> ComponentConfigModel<'a, RT> {
452453
)
453454
.await?
454455
},
455-
// Delete an existing node.
456-
(Some(existing_node), None) => self.delete_component(existing_node).await?,
456+
// Unmount an existing node.
457+
(Some(existing_node), None) => self.unmount_component(existing_node).await?,
457458
(None, None) => anyhow::bail!("Unexpected None/None in stack"),
458459
};
459460
diffs.insert(path.clone(), diff);
@@ -613,7 +614,7 @@ impl<'a, RT: Runtime> ComponentConfigModel<'a, RT> {
613614
))
614615
}
615616

616-
async fn delete_component(
617+
async fn unmount_component(
617618
&mut self,
618619
existing: &ParsedDocument<ComponentMetadata>,
619620
) -> anyhow::Result<(DeveloperDocumentId, ComponentDiff)> {
@@ -622,9 +623,10 @@ impl<'a, RT: Runtime> ComponentConfigModel<'a, RT> {
622623
} else {
623624
ComponentId::Child(existing.id().into())
624625
};
625-
// TODO: Delete the component's system tables.
626+
let mut unmounted_metadata = existing.clone().into_value();
627+
unmounted_metadata.state = ComponentState::Unmounted;
626628
SystemMetadataModel::new_global(self.tx)
627-
.delete(existing.id())
629+
.replace(existing.id(), unmounted_metadata.try_into()?)
628630
.await?;
629631
let module_diff = ModuleModel::new(self.tx)
630632
.apply(component_id, vec![], None, BTreeMap::new())
@@ -638,7 +640,7 @@ impl<'a, RT: Runtime> ComponentConfigModel<'a, RT> {
638640
Ok((
639641
existing.id().into(),
640642
ComponentDiff {
641-
diff_type: ComponentDiffType::Delete,
643+
diff_type: ComponentDiffType::Unmount,
642644
module_diff,
643645
udf_config_diff: None,
644646
cron_diff,
@@ -703,7 +705,7 @@ struct TreeDiffChild<'a> {
703705
pub enum ComponentDiffType {
704706
Create,
705707
Modify,
706-
Delete,
708+
Unmount,
707709
}
708710

709711
pub struct ComponentDiff {
@@ -718,7 +720,7 @@ pub struct ComponentDiff {
718720
pub enum SerializedComponentDiffType {
719721
Create,
720722
Modify,
721-
Delete,
723+
Unmount,
722724
}
723725

724726
#[derive(Serialize)]
@@ -737,7 +739,7 @@ impl TryFrom<ComponentDiffType> for SerializedComponentDiffType {
737739
Ok(match value {
738740
ComponentDiffType::Create => Self::Create,
739741
ComponentDiffType::Modify => Self::Modify,
740-
ComponentDiffType::Delete => Self::Delete,
742+
ComponentDiffType::Unmount => Self::Unmount,
741743
})
742744
}
743745
}

npm-packages/common/config/rush/pnpm-lock.yaml

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
2+
.env.local

0 commit comments

Comments
 (0)