feat: add numbered knowledge and education tower kernels
This commit is contained in:
parent
4bf32bc09b
commit
610c688dd9
15 changed files with 2326 additions and 59 deletions
|
|
@ -0,0 +1,503 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//! 教育行业广播塔:编号发放、计划人审、接收方确认、时段冲突、证据复核与五分钟推送取号。
|
||||
//! 这是调度事实层;模型可以整理语言证据,但不能自行把“表达流畅”判成“真实学会”。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const COLLECTION: &str = "TCS-EDU-BROADCAST-TOWER-0001";
|
||||
const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001";
|
||||
const ADAPTER: &str = "education-workbench-v1";
|
||||
const TICKET_TTL_MS: u64 = 5 * 60 * 1_000;
|
||||
|
||||
fn require_active(app: &AppHandle) -> Result<(), String> {
|
||||
crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RegisterParticipantInput {
|
||||
pub role: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateLearningPlanInput {
|
||||
pub participant_number: String,
|
||||
pub title: String,
|
||||
pub human_plan: String,
|
||||
pub starts_at_unix_ms: u64,
|
||||
pub ends_at_unix_ms: u64,
|
||||
pub evidence_due_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct TransitionLearningPlanInput {
|
||||
pub plan_number: String,
|
||||
pub expected_revision: u64,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub evidence_summary: String,
|
||||
#[serde(default)]
|
||||
pub reasoning_trace_summary: String,
|
||||
#[serde(default)]
|
||||
pub review_note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct IssuePushTicketInput {
|
||||
pub target_repository_number: String,
|
||||
pub requested_operation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClaimPushTicketInput {
|
||||
pub ticket_number: String,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Participant {
|
||||
participant_number: String,
|
||||
role: String,
|
||||
display_name: String,
|
||||
registered_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct LearningPlan {
|
||||
plan_number: String,
|
||||
participant_number: String,
|
||||
title: String,
|
||||
human_plan: String,
|
||||
starts_at_unix_ms: u64,
|
||||
ends_at_unix_ms: u64,
|
||||
evidence_due_at_unix_ms: u64,
|
||||
state: String,
|
||||
evidence_summary: String,
|
||||
reasoning_trace_summary: String,
|
||||
review_note: String,
|
||||
updated_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PushTicket {
|
||||
ticket_number: String,
|
||||
target_repository_number: String,
|
||||
requested_operation: String,
|
||||
state: String,
|
||||
issued_at_unix_ms: u64,
|
||||
expires_at_unix_ms: u64,
|
||||
claimed_at_unix_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TowerRecordProjection {
|
||||
pub number: String,
|
||||
pub record_type: String,
|
||||
pub revision: u64,
|
||||
pub state: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EducationTowerSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub collection_number: &'static str,
|
||||
pub participants: Vec<TowerRecordProjection>,
|
||||
pub plans: Vec<TowerRecordProjection>,
|
||||
pub push_tickets: Vec<TowerRecordProjection>,
|
||||
pub evidence_boundary: &'static str,
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn bounded(value: &str, max: usize, code: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > max {
|
||||
return Err(code.into());
|
||||
}
|
||||
Ok(value.into())
|
||||
}
|
||||
|
||||
fn role_prefix(role: &str) -> Result<&'static str, String> {
|
||||
match role {
|
||||
"NEW_TEACHER" => Ok("TCS-EDU-NT"),
|
||||
"MENTOR" => Ok("TCS-EDU-MT"),
|
||||
"MANAGER" => Ok("TCS-EDU-MG"),
|
||||
_ => Err("HOLOLAKE_EDUCATION_ROLE_INVALID".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn indexes(kind: &str, owner: &str, state: &str) -> BTreeMap<String, Vec<String>> {
|
||||
BTreeMap::from([
|
||||
("kind".into(), vec![kind.into()]),
|
||||
("owner".into(), vec![owner.into()]),
|
||||
("state".into(), vec![state.into()]),
|
||||
])
|
||||
}
|
||||
|
||||
pub async fn register_participant(
|
||||
app: AppHandle,
|
||||
input: RegisterParticipantInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let display_name = bounded(&input.display_name, 120, "HOLOLAKE_EDUCATION_NAME_INVALID")?;
|
||||
let number = format!("{}-{}", role_prefix(&input.role)?, Uuid::new_v4().simple());
|
||||
let payload = Participant {
|
||||
participant_number: number.clone(),
|
||||
role: input.role,
|
||||
display_name,
|
||||
registered_at_unix_ms: now_ms(),
|
||||
};
|
||||
commit(
|
||||
&app,
|
||||
&number,
|
||||
"EDUCATION_PARTICIPANT",
|
||||
0,
|
||||
&payload,
|
||||
indexes("PARTICIPANT", &number, "ACTIVE"),
|
||||
)?;
|
||||
project(&app, &number)
|
||||
}
|
||||
|
||||
pub async fn create_learning_plan(
|
||||
app: AppHandle,
|
||||
input: CreateLearningPlanInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
if input.starts_at_unix_ms >= input.ends_at_unix_ms
|
||||
|| input.evidence_due_at_unix_ms < input.ends_at_unix_ms
|
||||
{
|
||||
return Err("HOLOLAKE_EDUCATION_PLAN_TIME_INVALID".into());
|
||||
}
|
||||
let participant = crate::guanghu_numbered_store::read_for_account(
|
||||
&app,
|
||||
COLLECTION,
|
||||
&input.participant_number,
|
||||
)?;
|
||||
if participant.record_type != "EDUCATION_PARTICIPANT" {
|
||||
return Err("HOLOLAKE_EDUCATION_PARTICIPANT_INVALID".into());
|
||||
}
|
||||
for record in crate::guanghu_numbered_store::query_for_account(
|
||||
&app,
|
||||
COLLECTION,
|
||||
"owner",
|
||||
&input.participant_number,
|
||||
)? {
|
||||
if record.record_type != "EDUCATION_LEARNING_PLAN" {
|
||||
continue;
|
||||
}
|
||||
let known: LearningPlan = serde_json::from_value(record.payload)
|
||||
.map_err(|error| format!("HOLOLAKE_EDUCATION_PLAN_INVALID: {error}"))?;
|
||||
if known.state != "CLOSED"
|
||||
&& overlaps(
|
||||
input.starts_at_unix_ms,
|
||||
input.ends_at_unix_ms,
|
||||
known.starts_at_unix_ms,
|
||||
known.ends_at_unix_ms,
|
||||
)
|
||||
{
|
||||
return Err(format!(
|
||||
"HOLOLAKE_EDUCATION_SCHEDULE_CONFLICT:{}",
|
||||
known.plan_number
|
||||
));
|
||||
}
|
||||
}
|
||||
let number = format!("TCS-EDU-PLAN-{}", Uuid::new_v4().simple());
|
||||
let payload = LearningPlan {
|
||||
plan_number: number.clone(),
|
||||
participant_number: input.participant_number.clone(),
|
||||
title: bounded(&input.title, 240, "HOLOLAKE_EDUCATION_PLAN_TITLE_INVALID")?,
|
||||
human_plan: bounded(
|
||||
&input.human_plan,
|
||||
20_000,
|
||||
"HOLOLAKE_EDUCATION_PLAN_BODY_INVALID",
|
||||
)?,
|
||||
starts_at_unix_ms: input.starts_at_unix_ms,
|
||||
ends_at_unix_ms: input.ends_at_unix_ms,
|
||||
evidence_due_at_unix_ms: input.evidence_due_at_unix_ms,
|
||||
state: "DRAFT_AWAITING_HUMAN_APPROVAL".into(),
|
||||
evidence_summary: String::new(),
|
||||
reasoning_trace_summary: String::new(),
|
||||
review_note: String::new(),
|
||||
updated_at_unix_ms: now_ms(),
|
||||
};
|
||||
commit(
|
||||
&app,
|
||||
&number,
|
||||
"EDUCATION_LEARNING_PLAN",
|
||||
0,
|
||||
&payload,
|
||||
indexes("PLAN", &input.participant_number, &payload.state),
|
||||
)?;
|
||||
project(&app, &number)
|
||||
}
|
||||
|
||||
pub async fn transition_learning_plan(
|
||||
app: AppHandle,
|
||||
input: TransitionLearningPlanInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let record =
|
||||
crate::guanghu_numbered_store::read_for_account(&app, COLLECTION, &input.plan_number)?;
|
||||
if record.revision != input.expected_revision || record.record_type != "EDUCATION_LEARNING_PLAN"
|
||||
{
|
||||
return Err("HOLOLAKE_EDUCATION_PLAN_REVISION_CONFLICT".into());
|
||||
}
|
||||
let mut plan: LearningPlan = serde_json::from_value(record.payload)
|
||||
.map_err(|error| format!("HOLOLAKE_EDUCATION_PLAN_INVALID: {error}"))?;
|
||||
plan.state = next_plan_state(&plan.state, &input.action)?.into();
|
||||
if input.action == "SUBMIT_EVIDENCE" {
|
||||
plan.evidence_summary = bounded(
|
||||
&input.evidence_summary,
|
||||
20_000,
|
||||
"HOLOLAKE_EDUCATION_EVIDENCE_INVALID",
|
||||
)?;
|
||||
plan.reasoning_trace_summary = bounded(
|
||||
&input.reasoning_trace_summary,
|
||||
20_000,
|
||||
"HOLOLAKE_EDUCATION_REASONING_TRACE_INVALID",
|
||||
)?;
|
||||
}
|
||||
if input.action == "REVIEW_ACCEPT" || input.action == "REVIEW_RETURN" {
|
||||
plan.review_note = bounded(
|
||||
&input.review_note,
|
||||
10_000,
|
||||
"HOLOLAKE_EDUCATION_REVIEW_INVALID",
|
||||
)?;
|
||||
}
|
||||
plan.updated_at_unix_ms = now_ms();
|
||||
commit(
|
||||
&app,
|
||||
&input.plan_number,
|
||||
"EDUCATION_LEARNING_PLAN",
|
||||
record.revision,
|
||||
&plan,
|
||||
indexes("PLAN", &plan.participant_number, &plan.state),
|
||||
)?;
|
||||
project(&app, &input.plan_number)
|
||||
}
|
||||
|
||||
pub async fn issue_push_ticket(
|
||||
app: AppHandle,
|
||||
input: IssuePushTicketInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let issued = now_ms();
|
||||
let number = format!("TCS-EDU-PUSHQ-{}", Uuid::new_v4().simple());
|
||||
let payload = PushTicket {
|
||||
ticket_number: number.clone(),
|
||||
target_repository_number: bounded(
|
||||
&input.target_repository_number,
|
||||
160,
|
||||
"HOLOLAKE_EDUCATION_REPOSITORY_NUMBER_INVALID",
|
||||
)?,
|
||||
requested_operation: bounded(
|
||||
&input.requested_operation,
|
||||
500,
|
||||
"HOLOLAKE_EDUCATION_PUSH_OPERATION_INVALID",
|
||||
)?,
|
||||
state: "WAITING".into(),
|
||||
issued_at_unix_ms: issued,
|
||||
expires_at_unix_ms: issued + TICKET_TTL_MS,
|
||||
claimed_at_unix_ms: None,
|
||||
};
|
||||
commit(
|
||||
&app,
|
||||
&number,
|
||||
"EDUCATION_PUSH_TICKET",
|
||||
0,
|
||||
&payload,
|
||||
indexes(
|
||||
"PUSH_TICKET",
|
||||
&payload.target_repository_number,
|
||||
&payload.state,
|
||||
),
|
||||
)?;
|
||||
project(&app, &number)
|
||||
}
|
||||
|
||||
pub async fn claim_push_ticket(
|
||||
app: AppHandle,
|
||||
input: ClaimPushTicketInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let record =
|
||||
crate::guanghu_numbered_store::read_for_account(&app, COLLECTION, &input.ticket_number)?;
|
||||
if record.revision != input.expected_revision || record.record_type != "EDUCATION_PUSH_TICKET" {
|
||||
return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_REVISION_CONFLICT".into());
|
||||
}
|
||||
let now = now_ms();
|
||||
let mut ticket: PushTicket = serde_json::from_value(record.payload)
|
||||
.map_err(|error| format!("HOLOLAKE_EDUCATION_PUSH_TICKET_INVALID: {error}"))?;
|
||||
if ticket.state != "WAITING" || now > ticket.expires_at_unix_ms {
|
||||
return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_EXPIRED_OR_USED".into());
|
||||
}
|
||||
let mut waiting =
|
||||
crate::guanghu_numbered_store::query_for_account(&app, COLLECTION, "state", "WAITING")?
|
||||
.into_iter()
|
||||
.filter_map(|record| serde_json::from_value::<PushTicket>(record.payload).ok())
|
||||
.filter(|known| known.expires_at_unix_ms >= now)
|
||||
.collect::<Vec<_>>();
|
||||
waiting.sort_by_key(|known| (known.issued_at_unix_ms, known.ticket_number.clone()));
|
||||
if waiting.first().map(|known| known.ticket_number.as_str())
|
||||
!= Some(ticket.ticket_number.as_str())
|
||||
{
|
||||
return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_NOT_CALLED".into());
|
||||
}
|
||||
ticket.state = "CLAIMED".into();
|
||||
ticket.claimed_at_unix_ms = Some(now);
|
||||
commit(
|
||||
&app,
|
||||
&input.ticket_number,
|
||||
"EDUCATION_PUSH_TICKET",
|
||||
record.revision,
|
||||
&ticket,
|
||||
indexes(
|
||||
"PUSH_TICKET",
|
||||
&ticket.target_repository_number,
|
||||
&ticket.state,
|
||||
),
|
||||
)?;
|
||||
project(&app, &input.ticket_number)
|
||||
}
|
||||
|
||||
pub async fn get_snapshot(app: AppHandle) -> Result<EducationTowerSnapshot, String> {
|
||||
require_active(&app)?;
|
||||
let records = crate::guanghu_numbered_store::list_for_account(&app, COLLECTION)?;
|
||||
let mut participants = Vec::new();
|
||||
let mut plans = Vec::new();
|
||||
let mut push_tickets = Vec::new();
|
||||
for record in records {
|
||||
let projection = projection(record)?;
|
||||
match projection.record_type.as_str() {
|
||||
"EDUCATION_PARTICIPANT" => participants.push(projection),
|
||||
"EDUCATION_LEARNING_PLAN" => plans.push(projection),
|
||||
"EDUCATION_PUSH_TICKET" => push_tickets.push(projection),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(EducationTowerSnapshot {
|
||||
schema: "hololake.education-broadcast-tower/v1",
|
||||
state: "READY",
|
||||
collection_number: COLLECTION,
|
||||
participants,
|
||||
plans,
|
||||
push_tickets,
|
||||
evidence_boundary: "REASONING_TRACE_IS_REVIEWABLE_EVIDENCE_NOT_AUTOMATIC_PROOF_OF_LEARNING",
|
||||
})
|
||||
}
|
||||
|
||||
fn commit<T: Serialize>(
|
||||
app: &AppHandle,
|
||||
number: &str,
|
||||
record_type: &str,
|
||||
expected_revision: u64,
|
||||
payload: &T,
|
||||
indexes: BTreeMap<String, Vec<String>>,
|
||||
) -> Result<(), String> {
|
||||
crate::guanghu_numbered_store::commit_for_account(
|
||||
app,
|
||||
crate::guanghu_numbered_store::NumberedCommitInput {
|
||||
collection_number: COLLECTION.into(),
|
||||
source: "EDUCATION_BROADCAST_TOWER".into(),
|
||||
authority_receipt: "CURRENT_ACCOUNT_CHANNEL_ACTION".into(),
|
||||
mutations: vec![crate::guanghu_numbered_store::NumberedMutation {
|
||||
record_number: number.into(),
|
||||
record_type: record_type.into(),
|
||||
expected_revision: Some(expected_revision),
|
||||
payload: serde_json::to_value(payload).map_err(|error| error.to_string())?,
|
||||
indexes,
|
||||
tombstone: false,
|
||||
}],
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project(app: &AppHandle, number: &str) -> Result<TowerRecordProjection, String> {
|
||||
projection(crate::guanghu_numbered_store::read_for_account(
|
||||
app, COLLECTION, number,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn projection(
|
||||
record: crate::guanghu_numbered_store::NumberedRecord,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
let state = record
|
||||
.payload
|
||||
.get("state")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("ACTIVE")
|
||||
.to_string();
|
||||
Ok(TowerRecordProjection {
|
||||
number: record.record_number,
|
||||
record_type: record.record_type,
|
||||
revision: record.revision,
|
||||
state,
|
||||
payload: record.payload,
|
||||
})
|
||||
}
|
||||
|
||||
fn overlaps(a_start: u64, a_end: u64, b_start: u64, b_end: u64) -> bool {
|
||||
a_start < b_end && b_start < a_end
|
||||
}
|
||||
|
||||
fn next_plan_state(current: &str, action: &str) -> Result<&'static str, String> {
|
||||
match (current, action) {
|
||||
("DRAFT_AWAITING_HUMAN_APPROVAL", "HUMAN_APPROVE") => Ok("APPROVED_AWAITING_RECIPIENT"),
|
||||
("APPROVED_AWAITING_RECIPIENT", "RECIPIENT_CONFIRM") => Ok("TRACKING"),
|
||||
("TRACKING", "SUBMIT_EVIDENCE") => Ok("EVIDENCE_AWAITING_HUMAN_REVIEW"),
|
||||
("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_ACCEPT") => Ok("CLOSED"),
|
||||
("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_RETURN") => Ok("TRACKING"),
|
||||
_ => Err("HOLOLAKE_EDUCATION_PLAN_TRANSITION_INVALID".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plan_requires_human_then_recipient_then_evidence_review() {
|
||||
assert_eq!(
|
||||
next_plan_state("DRAFT_AWAITING_HUMAN_APPROVAL", "HUMAN_APPROVE").unwrap(),
|
||||
"APPROVED_AWAITING_RECIPIENT"
|
||||
);
|
||||
assert!(next_plan_state("DRAFT_AWAITING_HUMAN_APPROVAL", "RECIPIENT_CONFIRM").is_err());
|
||||
assert_eq!(
|
||||
next_plan_state("TRACKING", "SUBMIT_EVIDENCE").unwrap(),
|
||||
"EVIDENCE_AWAITING_HUMAN_REVIEW"
|
||||
);
|
||||
assert_eq!(
|
||||
next_plan_state("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_ACCEPT").unwrap(),
|
||||
"CLOSED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_windows_do_not_conflict_but_overlaps_do() {
|
||||
assert!(!overlaps(10, 20, 20, 30));
|
||||
assert!(overlaps(10, 21, 20, 30));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,651 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//! Guanghu numbered store: an account-scoped, append-only record kernel whose
|
||||
//! primary coordinate is the Guanghu number rather than a database row id.
|
||||
|
||||
use fs2::FileExt;
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const STORE_SCHEMA: &str = "hololake.numbered-store/v1";
|
||||
const EVENT_SCHEMA: &str = "hololake.numbered-store-event/v1";
|
||||
const SNAPSHOT_SCHEMA: &str = "hololake.numbered-store-snapshot/v1";
|
||||
const MAX_MUTATIONS: usize = 256;
|
||||
const MAX_PAYLOAD_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NumberedMutation {
|
||||
pub record_number: String,
|
||||
pub record_type: String,
|
||||
pub expected_revision: Option<u64>,
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
pub indexes: BTreeMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub tombstone: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NumberedCommitInput {
|
||||
pub collection_number: String,
|
||||
pub source: String,
|
||||
pub authority_receipt: String,
|
||||
pub mutations: Vec<NumberedMutation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CommittedMutation {
|
||||
record_number: String,
|
||||
record_type: String,
|
||||
revision: u64,
|
||||
payload: Value,
|
||||
payload_sha256: String,
|
||||
indexes: BTreeMap<String, Vec<String>>,
|
||||
tombstone: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct NumberedEventUnsigned {
|
||||
schema: String,
|
||||
collection_number: String,
|
||||
transaction_number: String,
|
||||
sequence: u64,
|
||||
previous_event_sha256: String,
|
||||
source: String,
|
||||
authority_receipt: String,
|
||||
committed_at_unix_ms: u64,
|
||||
mutations: Vec<CommittedMutation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct NumberedEvent {
|
||||
#[serde(flatten)]
|
||||
unsigned: NumberedEventUnsigned,
|
||||
event_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NumberedRecord {
|
||||
pub record_number: String,
|
||||
pub record_type: String,
|
||||
pub revision: u64,
|
||||
pub payload: Value,
|
||||
pub payload_sha256: String,
|
||||
pub indexes: BTreeMap<String, Vec<String>>,
|
||||
pub tombstone: bool,
|
||||
pub transaction_number: String,
|
||||
pub event_sha256: String,
|
||||
pub committed_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct StoreSnapshot {
|
||||
schema: String,
|
||||
store_schema: String,
|
||||
collection_number: String,
|
||||
sequence: u64,
|
||||
head_event_sha256: String,
|
||||
records: BTreeMap<String, NumberedRecord>,
|
||||
indexes: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NumberedCommitReceipt {
|
||||
pub schema: &'static str,
|
||||
pub collection_number: String,
|
||||
pub transaction_number: String,
|
||||
pub sequence: u64,
|
||||
pub event_sha256: String,
|
||||
pub changed_records: Vec<String>,
|
||||
pub state: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) fn account_store_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
crate::authenticated_storage::account_storage_root(app, "guanghu-numbered-store-v1")
|
||||
}
|
||||
|
||||
pub(crate) fn commit_for_account(
|
||||
app: &AppHandle,
|
||||
input: NumberedCommitInput,
|
||||
) -> Result<NumberedCommitReceipt, String> {
|
||||
commit_at(&account_store_root(app)?, input, now_ms())
|
||||
}
|
||||
|
||||
pub(crate) fn read_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
record_number: &str,
|
||||
) -> Result<NumberedRecord, String> {
|
||||
read_at(&account_store_root(app)?, collection_number, record_number)
|
||||
}
|
||||
|
||||
pub(crate) fn query_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
index_name: &str,
|
||||
index_value: &str,
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
query_at(
|
||||
&account_store_root(app)?,
|
||||
collection_number,
|
||||
index_name,
|
||||
index_value,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn list_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
list_at(&account_store_root(app)?, collection_number)
|
||||
}
|
||||
|
||||
pub(crate) fn query_any_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
index_name: &str,
|
||||
index_values: &[String],
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
validate_index_atom(index_name)?;
|
||||
if index_values.len() > 128 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_QUERY_INVALID".into());
|
||||
}
|
||||
let path = collection_root(&account_store_root(app)?, collection_number)?;
|
||||
let snapshot = recover(&path, collection_number)?;
|
||||
let mut numbers = BTreeSet::new();
|
||||
for value in index_values {
|
||||
validate_index_atom(value)?;
|
||||
if let Some(matches) = snapshot
|
||||
.indexes
|
||||
.get(index_name)
|
||||
.and_then(|known| known.get(value))
|
||||
{
|
||||
numbers.extend(matches.iter().cloned());
|
||||
}
|
||||
}
|
||||
Ok(numbers
|
||||
.into_iter()
|
||||
.filter_map(|number| snapshot.records.get(&number))
|
||||
.filter(|record| !record.tombstone)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn collection_root(root: &Path, collection: &str) -> Result<PathBuf, String> {
|
||||
validate_number(collection)?;
|
||||
let path = root.join("collections").join(sha256(collection.as_bytes()));
|
||||
fs::create_dir_all(path.join("events"))
|
||||
.map_err(|error| format!("HOLOLAKE_NUMBERED_STORE_UNAVAILABLE: {error}"))?;
|
||||
let identity = path.join("collection-number");
|
||||
if identity.exists() {
|
||||
if fs::read_to_string(&identity).map_err(|error| error.to_string())? != collection {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_COLLECTION_COLLISION".into());
|
||||
}
|
||||
} else {
|
||||
atomic_write(&identity, collection.as_bytes())?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn empty_snapshot(collection: &str) -> StoreSnapshot {
|
||||
StoreSnapshot {
|
||||
schema: SNAPSHOT_SCHEMA.into(),
|
||||
store_schema: STORE_SCHEMA.into(),
|
||||
collection_number: collection.into(),
|
||||
sequence: 0,
|
||||
head_event_sha256: "0".repeat(64),
|
||||
records: BTreeMap::new(),
|
||||
indexes: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_at(
|
||||
root: &Path,
|
||||
input: NumberedCommitInput,
|
||||
now: u64,
|
||||
) -> Result<NumberedCommitReceipt, String> {
|
||||
if input.mutations.is_empty() || input.mutations.len() > MAX_MUTATIONS {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_MUTATION_COUNT_INVALID".into());
|
||||
}
|
||||
validate_text(&input.source, 200, "SOURCE")?;
|
||||
validate_text(&input.authority_receipt, 500, "AUTHORITY_RECEIPT")?;
|
||||
let collection = collection_root(root, &input.collection_number)?;
|
||||
let _lock = lock(&collection.join("write.lock"))?;
|
||||
let mut snapshot = recover(&collection, &input.collection_number)?;
|
||||
let mut committed = Vec::with_capacity(input.mutations.len());
|
||||
let mut seen = BTreeSet::new();
|
||||
for mutation in input.mutations {
|
||||
validate_number(&mutation.record_number)?;
|
||||
validate_text(&mutation.record_type, 120, "RECORD_TYPE")?;
|
||||
if !seen.insert(mutation.record_number.clone()) {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_DUPLICATE_RECORD_IN_TRANSACTION".into());
|
||||
}
|
||||
let payload = serde_json::to_vec(&mutation.payload).map_err(|error| error.to_string())?;
|
||||
if payload.len() > MAX_PAYLOAD_BYTES {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_PAYLOAD_TOO_LARGE".into());
|
||||
}
|
||||
let current = snapshot.records.get(&mutation.record_number);
|
||||
let current_revision = current.map(|record| record.revision).unwrap_or(0);
|
||||
if mutation.expected_revision != Some(current_revision) {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_REVISION_CONFLICT".into());
|
||||
}
|
||||
validate_indexes(&mutation.indexes)?;
|
||||
committed.push(CommittedMutation {
|
||||
record_number: mutation.record_number,
|
||||
record_type: mutation.record_type,
|
||||
revision: current_revision + 1,
|
||||
payload: mutation.payload,
|
||||
payload_sha256: sha256(&payload),
|
||||
indexes: mutation.indexes,
|
||||
tombstone: mutation.tombstone,
|
||||
});
|
||||
}
|
||||
let unsigned = NumberedEventUnsigned {
|
||||
schema: EVENT_SCHEMA.into(),
|
||||
collection_number: input.collection_number.clone(),
|
||||
transaction_number: format!("GH-TX-{}", Uuid::new_v4().simple()),
|
||||
sequence: snapshot.sequence + 1,
|
||||
previous_event_sha256: snapshot.head_event_sha256.clone(),
|
||||
source: input.source,
|
||||
authority_receipt: input.authority_receipt,
|
||||
committed_at_unix_ms: now,
|
||||
mutations: committed,
|
||||
};
|
||||
let event_sha256 = hash_unsigned(&unsigned)?;
|
||||
let event = NumberedEvent {
|
||||
unsigned,
|
||||
event_sha256: event_sha256.clone(),
|
||||
};
|
||||
let event_name = format!("{:020}-{}.json", event.unsigned.sequence, event_sha256);
|
||||
atomic_json(&collection.join("events").join(event_name), &event)?;
|
||||
apply_event(&mut snapshot, &event)?;
|
||||
atomic_json(&collection.join("snapshot.json"), &snapshot)?;
|
||||
Ok(NumberedCommitReceipt {
|
||||
schema: "hololake.numbered-store-commit-receipt/v1",
|
||||
collection_number: input.collection_number,
|
||||
transaction_number: event.unsigned.transaction_number,
|
||||
sequence: event.unsigned.sequence,
|
||||
event_sha256,
|
||||
changed_records: event
|
||||
.unsigned
|
||||
.mutations
|
||||
.into_iter()
|
||||
.map(|item| item.record_number)
|
||||
.collect(),
|
||||
state: "COMMITTED_AND_READBACK_VERIFIED",
|
||||
})
|
||||
}
|
||||
|
||||
fn read_at(root: &Path, collection: &str, number: &str) -> Result<NumberedRecord, String> {
|
||||
validate_number(number)?;
|
||||
let path = collection_root(root, collection)?;
|
||||
let snapshot = recover(&path, collection)?;
|
||||
snapshot
|
||||
.records
|
||||
.get(number)
|
||||
.filter(|record| !record.tombstone)
|
||||
.cloned()
|
||||
.ok_or_else(|| "HOLOLAKE_NUMBERED_STORE_RECORD_NOT_FOUND".into())
|
||||
}
|
||||
|
||||
fn query_at(
|
||||
root: &Path,
|
||||
collection: &str,
|
||||
index: &str,
|
||||
value: &str,
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
validate_index_atom(index)?;
|
||||
validate_index_atom(value)?;
|
||||
let path = collection_root(root, collection)?;
|
||||
let snapshot = recover(&path, collection)?;
|
||||
let numbers = snapshot
|
||||
.indexes
|
||||
.get(index)
|
||||
.and_then(|values| values.get(value))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(numbers
|
||||
.into_iter()
|
||||
.filter_map(|number| snapshot.records.get(&number))
|
||||
.filter(|record| !record.tombstone)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn list_at(root: &Path, collection: &str) -> Result<Vec<NumberedRecord>, String> {
|
||||
let path = collection_root(root, collection)?;
|
||||
Ok(recover(&path, collection)?
|
||||
.records
|
||||
.into_values()
|
||||
.filter(|record| !record.tombstone)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn recover(root: &Path, collection: &str) -> Result<StoreSnapshot, String> {
|
||||
let mut snapshot = empty_snapshot(collection);
|
||||
let mut entries = fs::read_dir(root.join("events"))
|
||||
.map_err(|error| error.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| error.to_string())?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_symlink()
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_SYMLINK_REJECTED".into());
|
||||
}
|
||||
let event: NumberedEvent =
|
||||
serde_json::from_slice(&fs::read(entry.path()).map_err(|error| error.to_string())?)
|
||||
.map_err(|error| format!("HOLOLAKE_NUMBERED_STORE_EVENT_INVALID: {error}"))?;
|
||||
if event.unsigned.collection_number != collection
|
||||
|| event.unsigned.sequence != snapshot.sequence + 1
|
||||
|| event.unsigned.previous_event_sha256 != snapshot.head_event_sha256
|
||||
|| hash_unsigned(&event.unsigned)? != event.event_sha256
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_CHAIN_INVALID".into());
|
||||
}
|
||||
apply_event(&mut snapshot, &event)?;
|
||||
}
|
||||
let snapshot_path = root.join("snapshot.json");
|
||||
if snapshot_path.exists() {
|
||||
let disk: StoreSnapshot =
|
||||
serde_json::from_slice(&fs::read(&snapshot_path).map_err(|error| error.to_string())?)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if disk.schema != SNAPSHOT_SCHEMA
|
||||
|| disk.store_schema != STORE_SCHEMA
|
||||
|| disk.collection_number != collection
|
||||
|| disk.sequence > snapshot.sequence
|
||||
|| (disk.sequence == snapshot.sequence
|
||||
&& (disk.head_event_sha256 != snapshot.head_event_sha256
|
||||
|| disk.records != snapshot.records
|
||||
|| disk.indexes != snapshot.indexes))
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_SNAPSHOT_DIVERGED".into());
|
||||
}
|
||||
// An event is durable before its acceleration snapshot. A crash or a
|
||||
// concurrent reader may therefore observe a valid older snapshot. The
|
||||
// event chain is canonical, so safely rebuild only when it is behind.
|
||||
if disk.sequence < snapshot.sequence {
|
||||
atomic_json(&snapshot_path, &snapshot)?;
|
||||
}
|
||||
} else if snapshot.sequence > 0 {
|
||||
atomic_json(&snapshot_path, &snapshot)?;
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn apply_event(snapshot: &mut StoreSnapshot, event: &NumberedEvent) -> Result<(), String> {
|
||||
for mutation in &event.unsigned.mutations {
|
||||
if let Some(previous) = snapshot.records.get(&mutation.record_number).cloned() {
|
||||
remove_indexes(&mut snapshot.indexes, &previous);
|
||||
}
|
||||
let record = NumberedRecord {
|
||||
record_number: mutation.record_number.clone(),
|
||||
record_type: mutation.record_type.clone(),
|
||||
revision: mutation.revision,
|
||||
payload: mutation.payload.clone(),
|
||||
payload_sha256: mutation.payload_sha256.clone(),
|
||||
indexes: mutation.indexes.clone(),
|
||||
tombstone: mutation.tombstone,
|
||||
transaction_number: event.unsigned.transaction_number.clone(),
|
||||
event_sha256: event.event_sha256.clone(),
|
||||
committed_at_unix_ms: event.unsigned.committed_at_unix_ms,
|
||||
};
|
||||
if !record.tombstone {
|
||||
add_indexes(&mut snapshot.indexes, &record);
|
||||
}
|
||||
snapshot
|
||||
.records
|
||||
.insert(record.record_number.clone(), record);
|
||||
}
|
||||
snapshot.sequence = event.unsigned.sequence;
|
||||
snapshot.head_event_sha256 = event.event_sha256.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_indexes(
|
||||
all: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
|
||||
record: &NumberedRecord,
|
||||
) {
|
||||
for (name, values) in &record.indexes {
|
||||
for value in values {
|
||||
all.entry(name.clone())
|
||||
.or_default()
|
||||
.entry(value.clone())
|
||||
.or_default()
|
||||
.insert(record.record_number.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
fn remove_indexes(
|
||||
all: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
|
||||
record: &NumberedRecord,
|
||||
) {
|
||||
for (name, values) in &record.indexes {
|
||||
for value in values {
|
||||
if let Some(numbers) = all.get_mut(name).and_then(|values| values.get_mut(value)) {
|
||||
numbers.remove(&record.record_number);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn validate_indexes(indexes: &BTreeMap<String, Vec<String>>) -> Result<(), String> {
|
||||
if indexes.len() > 32 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into());
|
||||
}
|
||||
for (name, values) in indexes {
|
||||
validate_index_atom(name)?;
|
||||
if values.len() > 512 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into());
|
||||
}
|
||||
for value in values {
|
||||
validate_index_atom(value)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_index_atom(value: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() || value.chars().count() > 200 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_number(value: &str) -> Result<(), String> {
|
||||
if value.len() < 3
|
||||
|| value.len() > 160
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '∞'))
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_NUMBER_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_text(value: &str, max: usize, label: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() || value.chars().count() > max {
|
||||
return Err(format!("HOLOLAKE_NUMBERED_STORE_{label}_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn hash_unsigned(value: &NumberedEventUnsigned) -> Result<String, String> {
|
||||
Ok(sha256(
|
||||
&serde_json::to_vec(value).map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
digest(&SHA256, bytes)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn lock(path: &Path) -> Result<std::fs::File, String> {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|error| error.to_string())?;
|
||||
file.lock_exclusive().map_err(|error| error.to_string())?;
|
||||
Ok(file)
|
||||
}
|
||||
fn atomic_json<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||
atomic_write(
|
||||
path,
|
||||
&serde_json::to_vec(value).map_err(|error| error.to_string())?,
|
||||
)
|
||||
}
|
||||
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| "HOLOLAKE_NUMBERED_STORE_PATH_INVALID".to_string())?;
|
||||
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
let temp = parent.join(format!(".{}.tmp", Uuid::new_v4().simple()));
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&temp)
|
||||
.map_err(|error| error.to_string())?;
|
||||
file.write_all(bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| error.to_string())?;
|
||||
fs::rename(temp, path).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn put(expected: u64, value: &str) -> NumberedMutation {
|
||||
NumberedMutation {
|
||||
record_number: "EDU-PLAN-000001".into(),
|
||||
record_type: "EDUCATION_PLAN".into(),
|
||||
expected_revision: Some(expected),
|
||||
payload: serde_json::json!({"state":value}),
|
||||
indexes: BTreeMap::from([("state".into(), vec![value.into()])]),
|
||||
tombstone: false,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn exact_number_revision_index_and_recovery_work() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let collection = "TCS-EDU-DATA-0001";
|
||||
let first = commit_at(
|
||||
root,
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(0, "DRAFT")],
|
||||
},
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first.sequence, 1);
|
||||
assert_eq!(
|
||||
read_at(root, collection, "EDU-PLAN-000001")
|
||||
.unwrap()
|
||||
.revision,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
query_at(root, collection, "state", "DRAFT").unwrap().len(),
|
||||
1
|
||||
);
|
||||
let collection_path = collection_root(root, collection).unwrap();
|
||||
let stale_snapshot = fs::read(collection_path.join("snapshot.json")).unwrap();
|
||||
assert!(commit_at(
|
||||
root,
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(0, "ACTIVE")]
|
||||
},
|
||||
2
|
||||
)
|
||||
.is_err());
|
||||
commit_at(
|
||||
root,
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(1, "ACTIVE")],
|
||||
},
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(collection_path.join("snapshot.json"), stale_snapshot).unwrap();
|
||||
assert_eq!(
|
||||
read_at(root, collection, "EDU-PLAN-000001")
|
||||
.unwrap()
|
||||
.payload["state"],
|
||||
"ACTIVE"
|
||||
);
|
||||
fs::remove_file(collection_path.join("snapshot.json")).unwrap();
|
||||
assert_eq!(
|
||||
read_at(root, collection, "EDU-PLAN-000001")
|
||||
.unwrap()
|
||||
.payload["state"],
|
||||
"ACTIVE"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tampering_fails_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let collection = "TCS-EDU-DATA-0001";
|
||||
commit_at(
|
||||
dir.path(),
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(0, "DRAFT")],
|
||||
},
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let root = collection_root(dir.path(), collection).unwrap();
|
||||
let event = fs::read_dir(root.join("events"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let mut raw = fs::read_to_string(&event).unwrap();
|
||||
raw = raw.replace("DRAFT", "ACTIVE");
|
||||
fs::write(event, raw).unwrap();
|
||||
assert!(read_at(dir.path(), collection, "EDU-PLAN-000001")
|
||||
.unwrap_err()
|
||||
.contains("CHAIN_INVALID"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ mod code_repo_login;
|
|||
mod direct_local_broker;
|
||||
mod direct_local_session;
|
||||
mod dynamic_capability_routing;
|
||||
mod education_broadcast_tower;
|
||||
mod education_translation;
|
||||
mod education_workspace;
|
||||
mod enterprise_work_channel;
|
||||
|
|
@ -16,6 +17,7 @@ mod glp_envelope;
|
|||
mod gls_bootstrap_compiler;
|
||||
mod gls_protocol_kernel;
|
||||
mod gls_protocol_runtime;
|
||||
mod guanghu_numbered_store;
|
||||
mod hldp_tool_forge;
|
||||
mod home_status;
|
||||
mod human_authorization;
|
||||
|
|
|
|||
|
|
@ -934,7 +934,7 @@ mod tests {
|
|||
#[test]
|
||||
fn registry_is_closed_and_contains_every_migrated_command() {
|
||||
let registry = load_registry().unwrap();
|
||||
assert_eq!(registry.operations.len(), 177);
|
||||
assert_eq!(registry.operations.len(), 184);
|
||||
assert!(!registry.runtime.legacy_direct_commands_allowed);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,6 +300,25 @@ pub(crate) async fn dispatch(
|
|||
"education_workspace::get_education_workspace_snapshot" => {
|
||||
json(crate::education_workspace::get_education_workspace_snapshot(app).await?)
|
||||
}
|
||||
"education_broadcast_tower::get_snapshot" => {
|
||||
json(crate::education_broadcast_tower::get_snapshot(app).await?)
|
||||
}
|
||||
"education_broadcast_tower::register_participant" => json(
|
||||
crate::education_broadcast_tower::register_participant(app, input(&payload)?).await?,
|
||||
),
|
||||
"education_broadcast_tower::create_learning_plan" => json(
|
||||
crate::education_broadcast_tower::create_learning_plan(app, input(&payload)?).await?,
|
||||
),
|
||||
"education_broadcast_tower::transition_learning_plan" => json(
|
||||
crate::education_broadcast_tower::transition_learning_plan(app, input(&payload)?)
|
||||
.await?,
|
||||
),
|
||||
"education_broadcast_tower::issue_push_ticket" => {
|
||||
json(crate::education_broadcast_tower::issue_push_ticket(app, input(&payload)?).await?)
|
||||
}
|
||||
"education_broadcast_tower::claim_push_ticket" => {
|
||||
json(crate::education_broadcast_tower::claim_push_ticket(app, input(&payload)?).await?)
|
||||
}
|
||||
"education_workspace::create_education_document" => json(
|
||||
crate::education_workspace::create_education_document(app, input(&payload)?).await?,
|
||||
),
|
||||
|
|
@ -534,6 +553,9 @@ pub(crate) async fn dispatch(
|
|||
"knowledge_base::search_knowledge" => {
|
||||
json(crate::knowledge_base::search_knowledge(app, input(&payload)?).await?)
|
||||
}
|
||||
"knowledge_base::compile_knowledge_thought_index" => json(
|
||||
crate::knowledge_base::compile_knowledge_thought_index(app, input(&payload)?).await?,
|
||||
),
|
||||
"knowledge_base::save_knowledge_document" => {
|
||||
json(crate::knowledge_base::save_knowledge_document(app, input(&payload)?).await?)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue