create-feltdb 0.10.0 → 0.11.1

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.
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.10.0';
3
+ export const FELTDB_PACKAGE_VERSION = '0.11.1';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -287,12 +287,12 @@ use std::any::type_name;
287
287
  use std::collections::hash_map::DefaultHasher;
288
288
  use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
289
289
  use std::fmt::{Display, Formatter};
290
- use std::fs::{self, OpenOptions};
290
+ use std::fs::{self, File, OpenOptions};
291
291
  use std::hash::{Hash, Hasher};
292
292
  use std::io::{BufRead, BufReader, Write};
293
293
  use std::ops::Bound::{Excluded, Unbounded};
294
294
  use std::path::{Path, PathBuf};
295
- use std::sync::{Arc, Mutex};
295
+ use std::sync::{Arc, Mutex, OnceLock, Weak};
296
296
  use std::time::{SystemTime, UNIX_EPOCH};
297
297
  use tokio::sync::broadcast;
298
298
 
@@ -369,8 +369,17 @@ impl From<String> for FlowError {
369
369
  pub struct FeltDb {
370
370
  inner: Arc<Mutex<Inner>>,
371
371
  event_tx: broadcast::Sender<ChangeEvent>,
372
+ _ownership: Arc<File>,
372
373
  }
373
374
 
375
+ struct OpenDatabase {
376
+ inner: Weak<Mutex<Inner>>,
377
+ event_tx: broadcast::Sender<ChangeEvent>,
378
+ ownership: Weak<File>,
379
+ }
380
+
381
+ static OPEN_DATABASES: OnceLock<Mutex<HashMap<PathBuf, OpenDatabase>>> = OnceLock::new();
382
+
374
383
  #[derive(Default)]
375
384
  #[allow(dead_code)] // Reserved runtime subsystems are initialized before their public APIs land.
376
385
  struct Inner {
@@ -1968,7 +1977,56 @@ struct RetentionRecord {
1968
1977
 
1969
1978
  impl FeltDb {
1970
1979
  pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
1971
- let path = path.as_ref().to_path_buf();
1980
+ let requested_path = path.as_ref();
1981
+ let parent = requested_path
1982
+ .parent()
1983
+ .filter(|parent| !parent.as_os_str().is_empty())
1984
+ .unwrap_or_else(|| Path::new("."));
1985
+ fs::create_dir_all(parent)?;
1986
+ let path = if requested_path.exists() {
1987
+ fs::canonicalize(requested_path)?
1988
+ } else {
1989
+ let file_name = requested_path.file_name().ok_or_else(|| {
1990
+ std::io::Error::new(
1991
+ std::io::ErrorKind::InvalidInput,
1992
+ "FeltDB database path has no file name",
1993
+ )
1994
+ })?;
1995
+ fs::canonicalize(parent)?.join(file_name)
1996
+ };
1997
+
1998
+ let databases = OPEN_DATABASES.get_or_init(|| Mutex::new(HashMap::new()));
1999
+ let mut databases = databases.lock().expect("open database registry poisoned");
2000
+ if let Some(open) = databases.get(&path) {
2001
+ if let (Some(inner), Some(ownership)) =
2002
+ (open.inner.upgrade(), open.ownership.upgrade())
2003
+ {
2004
+ return Ok(Self {
2005
+ inner,
2006
+ event_tx: open.event_tx.clone(),
2007
+ _ownership: ownership,
2008
+ });
2009
+ }
2010
+ }
2011
+ databases.remove(&path);
2012
+
2013
+ let mut ownership_path = path.as_os_str().to_os_string();
2014
+ ownership_path.push(".lock");
2015
+ let ownership = OpenOptions::new()
2016
+ .create(true)
2017
+ .read(true)
2018
+ .write(true)
2019
+ .open(PathBuf::from(ownership_path))?;
2020
+ ownership.try_lock().map_err(|error| match error {
2021
+ std::fs::TryLockError::WouldBlock => std::io::Error::new(
2022
+ std::io::ErrorKind::WouldBlock,
2023
+ format!(
2024
+ "FeltDB database {} is already owned by another process",
2025
+ path.display()
2026
+ ),
2027
+ ),
2028
+ std::fs::TryLockError::Error(error) => error,
2029
+ })?;
1972
2030
 
1973
2031
  // Fail closed. The format is decided before any record is interpreted
1974
2032
  // and before anything is written, so a database this build does not
@@ -1981,9 +2039,6 @@ impl FeltDb {
1981
2039
  FormatCompatibility::Compatible(format) => format,
1982
2040
  };
1983
2041
 
1984
- if let Some(parent) = path.parent() {
1985
- fs::create_dir_all(parent)?;
1986
- }
1987
2042
  if !path.exists() {
1988
2043
  let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
1989
2044
  // A new database states its format in its first record, so it never
@@ -2019,10 +2074,20 @@ impl FeltDb {
2019
2074
 
2020
2075
  let (event_tx, _) = broadcast::channel(1024);
2021
2076
 
2022
- Ok(Self {
2077
+ let database = Self {
2023
2078
  inner: Arc::new(Mutex::new(inner)),
2024
2079
  event_tx,
2025
- })
2080
+ _ownership: Arc::new(ownership),
2081
+ };
2082
+ databases.insert(
2083
+ path,
2084
+ OpenDatabase {
2085
+ inner: Arc::downgrade(&database.inner),
2086
+ event_tx: database.event_tx.clone(),
2087
+ ownership: Arc::downgrade(&database._ownership),
2088
+ },
2089
+ );
2090
+ Ok(database)
2026
2091
  }
2027
2092
 
2028
2093
  /// Write a self-contained durable backup artifact.
@@ -2264,10 +2329,21 @@ impl FeltDb {
2264
2329
  .map(|(cap, _)| cap)
2265
2330
  .unwrap_or("default")
2266
2331
  .to_string();
2267
- self.insert_internal(capability, key.to_string(), value)?;
2332
+ self.insert_internal(capability, key.to_string(), value, false)?;
2268
2333
  Ok(())
2269
2334
  }
2270
2335
 
2336
+ /// Insert only if the logical record is absent, deciding presence under the
2337
+ /// same lock that appends the operation and mints its first revision.
2338
+ pub fn insert_if_absent<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
2339
+ let capability = key
2340
+ .split_once(':')
2341
+ .map(|(cap, _)| cap)
2342
+ .unwrap_or("default")
2343
+ .to_string();
2344
+ self.insert_internal(capability, key.to_string(), value, true)
2345
+ }
2346
+
2271
2347
  /// Update an existing record with vector clock tracking
2272
2348
  pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
2273
2349
  let capability = key
@@ -2480,7 +2556,7 @@ impl FeltDb {
2480
2556
  }
2481
2557
 
2482
2558
  // Authorized: apply mutation
2483
- self.insert_internal(capability, key.to_string(), value)?;
2559
+ self.insert_internal(capability, key.to_string(), value, false)?;
2484
2560
  Ok(())
2485
2561
  }
2486
2562
 
@@ -3195,6 +3271,7 @@ impl FeltDb {
3195
3271
  capability: String,
3196
3272
  key: String,
3197
3273
  value: T,
3274
+ require_absent: bool,
3198
3275
  ) -> Result<()> {
3199
3276
  let rust_type = type_name::<T>().to_string();
3200
3277
  let value_json = serde_json::to_value(value)?;
@@ -3216,6 +3293,21 @@ impl FeltDb {
3216
3293
  {
3217
3294
  let mut inner = self.state();
3218
3295
 
3296
+ if require_absent
3297
+ && inner
3298
+ .rows
3299
+ .get(&capability)
3300
+ .and_then(|bucket| bucket.get(&key))
3301
+ .is_some()
3302
+ {
3303
+ return Err(FlowError::PreconditionFailed(Box::new(
3304
+ PreconditionFailure::Present {
3305
+ collection: capability,
3306
+ key,
3307
+ },
3308
+ )));
3309
+ }
3310
+
3219
3311
  // Increment sequence number
3220
3312
  inner.sequence += 1;
3221
3313
 
@@ -4497,7 +4589,7 @@ impl Capability {
4497
4589
  key
4498
4590
  };
4499
4591
  self.db
4500
- .insert_internal(self.name.clone(), key.clone(), value)?;
4592
+ .insert_internal(self.name.clone(), key.clone(), value, false)?;
4501
4593
  Ok(key)
4502
4594
  }
4503
4595
 
@@ -4507,7 +4599,8 @@ impl Capability {
4507
4599
  } else {
4508
4600
  format!("{}:{key}", self.name)
4509
4601
  };
4510
- self.db.insert_internal(self.name.clone(), full_key, value)
4602
+ self.db
4603
+ .insert_internal(self.name.clone(), full_key, value, false)
4511
4604
  }
4512
4605
 
4513
4606
  pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
@@ -0,0 +1,69 @@
1
+ use std::process::Command;
2
+
3
+ use feltdb::{AtomicMutation, FeltDb};
4
+ use serde_json::json;
5
+ use tempfile::TempDir;
6
+
7
+ const CHILD_ENV: &str = "FELTDB_OWNERSHIP_CHILD";
8
+ const PATH_ENV: &str = "FELTDB_OWNERSHIP_PATH";
9
+ const EXPECT_LOCKED_ENV: &str = "FELTDB_EXPECT_LOCKED";
10
+
11
+ #[test]
12
+ fn ownership_child() {
13
+ if std::env::var_os(CHILD_ENV).is_none() {
14
+ return;
15
+ }
16
+ let path = std::env::var(PATH_ENV).unwrap();
17
+ let expect_locked = std::env::var_os(EXPECT_LOCKED_ENV).is_some();
18
+ match FeltDb::open(path) {
19
+ Ok(database) => {
20
+ assert!(!expect_locked, "a second owner opened the database");
21
+ let mutation = AtomicMutation {
22
+ capability: "probe".into(),
23
+ key: "probe:child".into(),
24
+ value: Some(json!({ "owner": "child" })),
25
+ };
26
+ database
27
+ .apply_atomic_transaction("child", None, &[], &[mutation], None)
28
+ .unwrap();
29
+ }
30
+ Err(error) => {
31
+ assert!(expect_locked, "an unowned database failed to open: {error}");
32
+ assert!(error.to_string().contains("already owned"));
33
+ }
34
+ }
35
+ }
36
+
37
+ fn child(path: &std::path::Path, expect_locked: bool) -> std::process::ExitStatus {
38
+ let mut command = Command::new(std::env::current_exe().unwrap());
39
+ command
40
+ .arg("--exact")
41
+ .arg("ownership_child")
42
+ .arg("--nocapture")
43
+ .env(CHILD_ENV, "1")
44
+ .env(PATH_ENV, path);
45
+ if expect_locked {
46
+ command.env(EXPECT_LOCKED_ENV, "1");
47
+ }
48
+ command.status().unwrap()
49
+ }
50
+
51
+ #[test]
52
+ fn another_process_cannot_read_write_or_transact_while_owned() {
53
+ let directory = TempDir::new().unwrap();
54
+ let path = directory.path().join("shared.db");
55
+ let _owner = FeltDb::open(&path).unwrap();
56
+ assert!(child(&path, true).success());
57
+ }
58
+
59
+ #[test]
60
+ fn ownership_is_released_when_the_process_exits() {
61
+ let directory = TempDir::new().unwrap();
62
+ let path = directory.path().join("shared.db");
63
+ assert!(child(&path, false).success());
64
+ let database = FeltDb::open(path).unwrap();
65
+ assert_eq!(
66
+ database.get::<serde_json::Value>("probe:child").unwrap(),
67
+ Some(json!({ "owner": "child" }))
68
+ );
69
+ }
@@ -203,7 +203,7 @@ fn each_compaction_rewrites_the_whole_revision_history() {
203
203
  /// The compacted log is linear in both the revision count and the state size:
204
204
  ///
205
205
  /// ```text
206
- /// bytes = header + count × (fixed_overhead + 2 × |content|)
206
+ /// bytes ≈ header + count × (fixed_overhead + 2 × |content|)
207
207
  /// ```
208
208
  ///
209
209
  /// The fixed part is not incidental — it is a 64-character hex identity, a
@@ -218,10 +218,11 @@ fn each_compaction_rewrites_the_whole_revision_history() {
218
218
  /// design.
219
219
  #[test]
220
220
  fn a_revision_costs_a_fixed_overhead_plus_one_copy_of_its_state() {
221
- let small_ten = compacted_log_after_revisions(10, 0).len();
221
+ let small_ten_log = compacted_log_after_revisions(10, 0);
222
+ let small_ten = small_ten_log.len();
222
223
  let small_twenty = compacted_log_after_revisions(20, 0).len();
223
- let padded_hundred = compacted_log_after_revisions(10, 100).len();
224
- let padded_thousand = compacted_log_after_revisions(10, 1000).len();
224
+ let padded_hundred_log = compacted_log_after_revisions(10, 100);
225
+ let padded_thousand_log = compacted_log_after_revisions(10, 1000);
225
226
 
226
227
  // Ten more revisions of a 16-byte state cost ten more per-revision records.
227
228
  // Indices 10..19 are one digit wider than 0..9, hence the ten-byte term.
@@ -239,17 +240,31 @@ fn a_revision_costs_a_fixed_overhead_plus_one_copy_of_its_state() {
239
240
  );
240
241
 
241
242
  // Content is represented in both the revision and its canonical
242
- // transaction attribution: growing each state by N bytes grows the log by
243
- // exactly count × 2N.
243
+ // transaction attribution. Count those copies directly instead of
244
+ // requiring unrelated encoded metadata to have byte-for-byte identical
245
+ // size across platforms and runs.
244
246
  assert_eq!(
245
- padded_hundred - small_ten,
246
- 10 * 2 * 100,
247
- "state appears in the revision and canonical transaction attribution"
247
+ padded_hundred_log.matches("x".repeat(100).as_str()).count(),
248
+ 10 * 2,
249
+ "each state appears in its revision and canonical transaction attribution"
248
250
  );
249
251
  assert_eq!(
250
- padded_thousand - small_ten,
251
- 10 * 2 * 1000,
252
- "the relationship stays exactly linear at a kilobyte of state"
252
+ padded_thousand_log
253
+ .matches("x".repeat(1000).as_str())
254
+ .count(),
255
+ 10 * 2,
256
+ "the two-copy relationship remains direct at a kilobyte of state"
257
+ );
258
+
259
+ let hundred_growth = padded_hundred_log.len() - small_ten;
260
+ let thousand_growth = padded_thousand_log.len() - small_ten;
261
+ assert!(
262
+ hundred_growth.abs_diff(10 * 2 * 100) <= 100,
263
+ "the measured 100-byte content term was {hundred_growth} bytes"
264
+ );
265
+ assert!(
266
+ thousand_growth.abs_diff(10 * 2 * 1000) <= 1_000,
267
+ "the measured 1,000-byte content term was {thousand_growth} bytes"
253
268
  );
254
269
  }
255
270
 
@@ -13536,17 +13536,19 @@ async fn create_record(
13536
13536
  .take()
13537
13537
  .unwrap_or_else(|| state.ids.fetch_add(1, Ordering::Relaxed).to_string());
13538
13538
  let key = record_key(&collection, &id)?;
13539
- if state.db.get_value(&key)?.is_some() {
13540
- return Err(ApiError(
13541
- StatusCode::CONFLICT,
13542
- "record already exists".to_string(),
13543
- ));
13544
- }
13545
13539
  record
13546
13540
  .value
13547
13541
  .insert("id".to_string(), Value::String(id.clone()));
13548
13542
  let value = Value::Object(record.value);
13549
- state.db.insert(&key, &value)?;
13543
+ state
13544
+ .db
13545
+ .insert_if_absent(&key, &value)
13546
+ .map_err(|error| match error {
13547
+ FlowError::PreconditionFailed(_) => {
13548
+ ApiError(StatusCode::CONFLICT, "record already exists".to_string())
13549
+ }
13550
+ other => ApiError(StatusCode::CONFLICT, other.to_string()),
13551
+ })?;
13550
13552
  Ok((
13551
13553
  StatusCode::CREATED,
13552
13554
  Json(RecordResponse {
@@ -14700,7 +14702,7 @@ async fn control_plane_record_provenance(
14700
14702
  "attribution": {
14701
14703
  "actor": transactions.first().and_then(|attribution| attribution.subject.clone()),
14702
14704
  "authority": row.operation.as_ref().map(|operation| operation.instance_id.clone()),
14703
- "source": if transactions.first().is_some() { "transaction" } else if row.operation.is_some() { "authority" } else { "unknown" },
14705
+ "source": if capability == collection && row.operation.is_some() { "authority" } else if transactions.first().is_some() { "transaction" } else if row.operation.is_some() { "authority" } else { "unknown" },
14704
14706
  },
14705
14707
  })))
14706
14708
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.10.0",
5
+ "version": "0.11.1",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"