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.
- package/dist/cli.js +4 -2
- package/dist/create.js +42 -6
- package/dist/frameworks.js +18 -0
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +408 -178
- package/dist/server-source/crates/feltdb/src/lib.rs +49 -5
- package/dist/server-source/crates/feltdb/src/multi_operation_transaction.rs +659 -0
- package/dist/server-source/crates/feltdb/src/p1_application_atomicity.rs +285 -0
- package/dist/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +582 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +141 -0
- package/dist/workspace-initialization.js +4 -5
- package/package.json +1 -1
- package/dist/server-source/crates/feltdb/src/phase_1c_three_node.rs +0 -523
|
@@ -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
|
-
*
|
|
13
|
+
* Opaque format: ws_<random>. Project identity is stored separately.
|
|
13
14
|
*/
|
|
14
|
-
export function generateWorkspaceId(
|
|
15
|
-
|
|
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
|