create-feltdb 0.6.9 → 0.6.11

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.
@@ -41,6 +41,7 @@ use feltdb::{
41
41
  Grant, GrantSigner, GrantStore, Subject as GrantSubject,
42
42
  },
43
43
  cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
44
+ AtomicMutation, AtomicPrecondition,
44
45
  policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
45
46
  state_contract::{
46
47
  begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
@@ -6298,6 +6299,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6298
6299
  "/api/connections/{connection_id}",
6299
6300
  axum::routing::delete(delete_connection_control),
6300
6301
  )
6302
+ .route("/transactions", axum::routing::post(commit_transaction))
6303
+ .route("/revision", get(get_revision))
6301
6304
  .route(
6302
6305
  "/collections/{collection}",
6303
6306
  get(list_records).post(create_record),
@@ -8974,6 +8977,30 @@ fn put_canonical(
8974
8977
  }))
8975
8978
  }
8976
8979
 
8980
+ /// The authority's current committed-state revision.
8981
+ ///
8982
+ /// This is the value `apply_atomic_transaction` advances and writes into each
8983
+ /// durable transaction record as `state_after`. It already survived restart and
8984
+ /// already refused to move backwards; the only thing missing was a way to read
8985
+ /// it without performing a write, which is what a cache needs.
8986
+ ///
8987
+ /// `scope` is the store's instance id. A revision is comparable only against
8988
+ /// another revision carrying the same scope, so a client that fails over to a
8989
+ /// different replica sees a scope change rather than a silently wrong
8990
+ /// comparison against a counter that means something else there.
8991
+ #[derive(Serialize)]
8992
+ struct RevisionResponse {
8993
+ revision: u64,
8994
+ scope: String,
8995
+ }
8996
+
8997
+ async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResponse>, ApiError> {
8998
+ Ok(Json(RevisionResponse {
8999
+ revision: state.db.sequence()?,
9000
+ scope: state.db.instance_id()?,
9001
+ }))
9002
+ }
9003
+
8977
9004
  async fn list_records(
8978
9005
  State(state): State<AppState>,
8979
9006
  Path(collection): Path<String>,
@@ -9000,6 +9027,120 @@ async fn get_record(
9000
9027
  }))
9001
9028
  }
9002
9029
 
9030
+ /// One operation inside an application transaction.
9031
+ #[derive(Deserialize)]
9032
+ #[serde(rename_all = "camelCase")]
9033
+ struct AtomicTxOperationRequest {
9034
+ collection: String,
9035
+ id: String,
9036
+ /// Absent value means delete.
9037
+ #[serde(default)]
9038
+ value: Option<Map<String, Value>>,
9039
+ /// When set, the record must not already exist for the transaction to commit.
9040
+ #[serde(default)]
9041
+ require_absent: bool,
9042
+ }
9043
+
9044
+ #[derive(Deserialize)]
9045
+ #[serde(rename_all = "camelCase")]
9046
+ struct AtomicTxRequest {
9047
+ /// Caller-supplied identity. Reusing one makes the commit idempotent.
9048
+ transaction_id: String,
9049
+ operations: Vec<AtomicTxOperationRequest>,
9050
+ }
9051
+
9052
+ #[derive(Serialize)]
9053
+ #[serde(rename_all = "camelCase")]
9054
+ struct AtomicTxResponse {
9055
+ transaction_id: String,
9056
+ /// True when this id had already been committed; nothing was reapplied.
9057
+ duplicate: bool,
9058
+ operations: usize,
9059
+ state_before: u64,
9060
+ state_after: u64,
9061
+ }
9062
+
9063
+ /// Commit several operations as one atomic, durable transaction.
9064
+ ///
9065
+ /// This is the application-facing entry point to the same atomic commit path
9066
+ /// the substrate uses: preconditions are evaluated first, then one transaction
9067
+ /// record is written and fsynced, and only then is derived state updated.
9068
+ /// There is no second transaction mechanism — the request is translated into
9069
+ /// the store's own atomic transaction and nothing else.
9070
+ async fn commit_transaction(
9071
+ State(state): State<AppState>,
9072
+ Json(request): Json<AtomicTxRequest>,
9073
+ ) -> Result<(StatusCode, Json<AtomicTxResponse>), ApiError> {
9074
+ if request.transaction_id.trim().is_empty() {
9075
+ return Err(ApiError(
9076
+ StatusCode::BAD_REQUEST,
9077
+ "transactionId is required".to_string(),
9078
+ ));
9079
+ }
9080
+ if request.operations.is_empty() {
9081
+ return Err(ApiError(
9082
+ StatusCode::BAD_REQUEST,
9083
+ "a transaction requires at least one operation".to_string(),
9084
+ ));
9085
+ }
9086
+
9087
+ // Validate every operation before any of them is turned into a mutation, so
9088
+ // a malformed request cannot commit a prefix of the transaction.
9089
+ let mut mutations = Vec::with_capacity(request.operations.len());
9090
+ let mut preconditions = Vec::new();
9091
+ let mut seen = std::collections::HashSet::new();
9092
+ for operation in request.operations {
9093
+ let key = record_key(&operation.collection, &operation.id)?;
9094
+ if !seen.insert(key.clone()) {
9095
+ return Err(ApiError(
9096
+ StatusCode::BAD_REQUEST,
9097
+ format!("transaction writes {key} more than once"),
9098
+ ));
9099
+ }
9100
+ if operation.require_absent {
9101
+ // The store reads a precondition with no expected version as
9102
+ // "this record must not exist", and evaluates it inside the same
9103
+ // lock as the commit, so there is no window between the check and
9104
+ // the write.
9105
+ preconditions.push(AtomicPrecondition {
9106
+ capability: operation.collection.clone(),
9107
+ key: key.clone(),
9108
+ expected_version: None,
9109
+ });
9110
+ }
9111
+ let value = operation.value.map(|mut fields| {
9112
+ fields.insert("id".to_string(), Value::String(operation.id.clone()));
9113
+ Value::Object(fields)
9114
+ });
9115
+ mutations.push(AtomicMutation {
9116
+ capability: operation.collection,
9117
+ key,
9118
+ value,
9119
+ });
9120
+ }
9121
+
9122
+ let operations = mutations.len();
9123
+ let commit = state
9124
+ .db
9125
+ .apply_atomic_transaction(&request.transaction_id, None, &preconditions, &mutations, None)
9126
+ .map_err(|error| {
9127
+ // A refused transaction applied nothing, so this is a conflict, not
9128
+ // a partially completed request.
9129
+ ApiError(StatusCode::CONFLICT, error.to_string())
9130
+ })?;
9131
+
9132
+ Ok((
9133
+ if commit.duplicate { StatusCode::OK } else { StatusCode::CREATED },
9134
+ Json(AtomicTxResponse {
9135
+ transaction_id: commit.transaction_id,
9136
+ duplicate: commit.duplicate,
9137
+ operations,
9138
+ state_before: commit.state_before,
9139
+ state_after: commit.state_after,
9140
+ }),
9141
+ ))
9142
+ }
9143
+
9003
9144
  async fn create_record(
9004
9145
  State(state): State<AppState>,
9005
9146
  Path(collection): Path<String>,
@@ -7,14 +7,13 @@
7
7
  */
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
+ import { randomBytes } from 'crypto';
10
11
  /**
11
12
  * Generate a unique workspace ID
12
- * Format: ws_<projectId>_<timestamp>_<random>
13
+ * Opaque format: ws_<random>. Project identity is stored separately.
13
14
  */
14
- export function generateWorkspaceId(projectId) {
15
- const timestamp = Date.now();
16
- const random = Math.random().toString(36).substring(2, 9);
17
- return `ws_${projectId}_${timestamp}_${random}`;
15
+ export function generateWorkspaceId(_projectId) {
16
+ return `ws_${randomBytes(16).toString('hex')}`;
18
17
  }
19
18
  /**
20
19
  * Initialize development workspace for a new project
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.6.9",
5
+ "version": "0.6.11",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"