diff --git a/.gitignore b/.gitignore index f70f354..d40ee4f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,5 @@ bun.lock Cargo.lock *.tsbuildinfo -packages/ts-types/src/generated/* -!packages/ts-types/src/generated/.gitkeep - # Personal study files, not part of the real schema build. /examples/ diff --git a/README.md b/README.md index f97776d..fab6148 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,47 @@ # continuum-schemas -Single source of truth for the Continuum data model. Protobuf definitions in -`proto/` are compiled into two consumer packages: +Single source of truth for the Continuum data model. Everything here is +JSON Schema, compiled into two consumer packages: -- `packages/ts-types` - TypeScript types generated via `ts-proto`, plus - hand-written [TypeBox](https://github.com/sinclairzx81/typebox) schemas - under `src/schemas/` used for runtime validation in `continuum-backend`'s - ElysiaJS routes. -- `packages/rust-types` - a Rust crate (`continuum-rust-types`) that compiles - the same `.proto` files with `prost-build` (via `build.rs`), producing - `serde`-derived structs for `continuum-proxy` and `continuum-ai-worker`. +- `packages/ts-types` - hand-written [TypeBox](https://github.com/sinclairzx81/typebox) + schemas under `src/schemas/` used for runtime validation in + `continuum-backend`'s ElysiaJS routes. +- `packages/rust-types` - a Rust crate (`continuum-rust-types`) with + hand-written, `serde`-derived structs for `continuum-proxy` and + `continuum-ai-worker`. ## Layout ``` -proto/continuum/v1/ canonical .proto message definitions -packages/ts-types/ TypeBox schemas + generated TS proto types -packages/rust-types/ prost-generated Rust structs (build.rs codegen) +schemas/continuum/v1/ Continuum's own domain model (PrintJob, Template, ...) +schemas/bambulab/ Bambu Lab MQTT wire schemas (v1/v2, report + request) +packages/ts-types/ TypeBox schemas (hand-written, mirrors schemas/) +packages/rust-types/ serde structs (hand-written, mirrors schemas/) ``` -## Build +`schemas/continuum/v1/*.schema.json` is the canonical shape for each +domain object; `packages/ts-types/src/schemas/` and +`packages/rust-types/src/lib.rs` both mirror it by hand. There are only +four small, stable DTOs here (`PrintJob`, `MaterialProfile`, +`PrintParameters`, `Template`), so keeping two hand-synced copies in sync +is less overhead than running a codegen pipeline (`protoc`/`ts-proto`/ +`prost-build`) nothing actually consumed - see git history for the +protobuf version this replaced, and why it was dropped: every service in +this system talks JSON (JSON Schema for the wire, TypeBox/serde for +runtime validation), so protobuf's actual value - a single schema compiled +into a compact **binary** wire format for both languages - never applied. -Requires `protoc` on `PATH` and `npm install` run at the repo root first. +Whenever a domain schema changes, update all three by hand: the JSON +Schema file, the TypeBox schema, and the Rust struct. + +## Build / verify + +Requires `npm install` at the repo root, and a Rust toolchain, first. ``` npm install -npm run build:ts # protoc + ts-proto -> packages/ts-types/src/generated -npm run build:rust # cargo build, runs build.rs -> prost-generated structs -npm run build # both +npm run build:rust # cargo build, confirms the Rust structs compile +npm run typecheck # tsc -b, confirms the TypeBox schemas compile ``` -Generated TS output under `packages/ts-types/src/generated` is build-derived -and gitignored. The Rust crate's generated code lands in `OUT_DIR` per -standard `prost-build` conventions and is never committed. +There's no `build:ts` step - the TS side is hand-written, not generated. diff --git a/package.json b/package.json index df795c8..b8a4ad3 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,11 @@ "packages/*" ], "scripts": { - "build:ts": "protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=packages/ts-types/src/generated --ts_proto_opt=esModuleInterop=true,outputServices=false,useOptionals=messages,outputIndex=true -I proto $(find proto -name '*.proto')", "build:rust": "cargo build -p continuum-rust-types --manifest-path packages/rust-types/Cargo.toml", - "build": "npm run build:ts && npm run build:rust", + "build": "npm run build:rust", "typecheck": "tsc -b" }, "devDependencies": { - "ts-proto": "^1.181.0", "typescript": "^5.5.0" } } diff --git a/packages/rust-types/Cargo.toml b/packages/rust-types/Cargo.toml index 1148332..0bb4ca1 100644 --- a/packages/rust-types/Cargo.toml +++ b/packages/rust-types/Cargo.toml @@ -8,11 +8,7 @@ name = "continuum_types" path = "src/lib.rs" [dependencies] -prost = "0.13" serde = { version = "1", features = ["derive"] } -[build-dependencies] -prost-build = "0.13" - [dev-dependencies] serde_json = "1" diff --git a/packages/rust-types/build.rs b/packages/rust-types/build.rs deleted file mode 100644 index 3bf15a6..0000000 --- a/packages/rust-types/build.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::path::PathBuf; - -fn main() { - let proto_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../proto") - .canonicalize() - .expect("proto root must exist"); - - let proto_files = [ - proto_root.join("continuum/v1/material.proto"), - proto_root.join("continuum/v1/print_params.proto"), - proto_root.join("continuum/v1/template.proto"), - proto_root.join("continuum/v1/print_job.proto"), - ]; - - for file in &proto_files { - println!("cargo:rerun-if-changed={}", file.display()); - } - - let mut config = prost_build::Config::new(); - config.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]"); - config.type_attribute(".", "#[serde(rename_all = \"camelCase\")]"); - - // prost stores proto3 enums as plain `i32` fields (not the generated - // enum type), so the blanket type_attribute above never actually runs - // for `status` - it'd serialize as a bare number, disagreeing with - // TypeBox's full-name string literals in ts-types. Route the field - // through PrintJobStatus's own as_str_name()/from_str_name() instead, - // matching protobuf's own canonical JSON enum representation. - config.field_attribute( - ".continuum.v1.PrintJob.status", - "#[serde(with = \"crate::print_job_status_serde\")]", - ); - - config - .compile_protos(&proto_files, &[proto_root]) - .expect("failed to compile continuum .proto sources"); -} diff --git a/packages/rust-types/src/lib.rs b/packages/rust-types/src/lib.rs index 697b532..02ea8fe 100644 --- a/packages/rust-types/src/lib.rs +++ b/packages/rust-types/src/lib.rs @@ -1,46 +1,80 @@ -pub mod continuum { - pub mod v1 { - include!(concat!(env!("OUT_DIR"), "/continuum.v1.rs")); - } +// Hand-written structs mirroring the JSON Schemas in `schemas/continuum/v1/` +// and the TypeBox schemas in `ts-types/src/schemas/` - this used to be +// prost-generated from `.proto` sources, but nothing here needed protobuf's +// actual value (cross-language binary codegen): both consumers (this crate +// and ts-types) only ever exchange JSON, so a hand-synced struct is less +// machinery than a `protoc`/`prost-build` pipeline for the same three +// flat DTOs. Keep field names and the `PrintJobStatus` string values in +// sync with `ts-types/src/schemas/` by hand when either side changes. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MaterialProfile { + pub id: String, + pub name: String, + pub filament_type: String, + pub nozzle_temp_c: f64, + pub bed_temp_c: f64, + pub density_g_cm3: f64, + pub color_hex: String, } -pub use continuum::v1::{ - MaterialProfile, PrintJob, PrintJobStatus, PrintParameters, Template, -}; - -// PrintJob.status is generated as a plain i32 (see build.rs's comment) - -// (de)serialize it via PrintJobStatus's full proto name strings -// (e.g. "PRINT_JOB_STATUS_QUEUED") so Rust's JSON matches ts-types' -// TypeBox schema and protobuf's own canonical JSON enum mapping. -mod print_job_status_serde { - use super::PrintJobStatus; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - - pub fn serialize(value: &i32, serializer: S) -> Result { - let status = PrintJobStatus::try_from(*value).unwrap_or(PrintJobStatus::Unspecified); - status.as_str_name().serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - let name = String::deserialize(deserializer)?; - PrintJobStatus::from_str_name(&name) - .map(|status| status as i32) - .ok_or_else(|| serde::de::Error::custom(format!("unknown PrintJobStatus: {name}"))) - } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintParameters { + pub layer_height_mm: f64, + pub infill_percent: i32, + pub infill_pattern: String, + pub print_speed_mm_s: f64, + pub supports_enabled: bool, + pub wall_count: i32, } -// Bambu's raw MQTT command/report structs are NOT modeled here - they're -// plain hand-written Rust + serde in continuum-proxy's -// src/printer/bambu_commands/, not proto. That data is JSON-over-MQTT -// (not binary protobuf), and only continuum-proxy ever needs to parse it -// - none of protobuf's actual value (cross-language codegen, binary wire -// efficiency) applies, and forcing it through prost-build's codegen model -// anyway produced real, avoidable friction (see this crate's git history -// for the specifics: a field/variant attribute-matching collision on -// `oneof`, needing package-scoped `type_attribute` calls, explicit -// `field_attribute(..., "#[serde(default)]")` for fields that aren't -// always present - every one of these disappears with a hand-written -// struct, where `Option` and `#[serde(flatten)]` just work with zero -// configuration). Proto stays for continuum.v1 above, which - unlike -// this - is a schema we designed ourselves and that both TS and Rust -// genuinely need. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Template { + pub id: String, + pub name: String, + pub description: String, + pub owner_id: String, + pub model_file_key: String, + pub material: MaterialProfile, + pub print_parameters: PrintParameters, + pub created_at_unix: i64, + pub updated_at_unix: i64, + pub tags: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrintJobStatus { + #[serde(rename = "PRINT_JOB_STATUS_UNSPECIFIED")] + Unspecified, + #[serde(rename = "PRINT_JOB_STATUS_QUEUED")] + Queued, + #[serde(rename = "PRINT_JOB_STATUS_PRINTING")] + Printing, + #[serde(rename = "PRINT_JOB_STATUS_PAUSED")] + Paused, + #[serde(rename = "PRINT_JOB_STATUS_COMPLETED")] + Completed, + #[serde(rename = "PRINT_JOB_STATUS_FAILED")] + Failed, + #[serde(rename = "PRINT_JOB_STATUS_CANCELLED")] + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintJob { + pub id: String, + pub farm_id: String, + pub printer_id: String, + pub template_id: String, + pub status: PrintJobStatus, + pub progress_percent: f64, + pub started_at_unix: i64, + pub completed_at_unix: i64, + pub gcode_file_key: String, +} diff --git a/packages/rust-types/tests/print_job_status.rs b/packages/rust-types/tests/print_job_status.rs index 3544758..8d8046e 100644 --- a/packages/rust-types/tests/print_job_status.rs +++ b/packages/rust-types/tests/print_job_status.rs @@ -6,7 +6,7 @@ fn sample(status: PrintJobStatus) -> PrintJob { farm_id: "farm-1".into(), printer_id: "printer-1".into(), template_id: "template-1".into(), - status: status as i32, + status, progress_percent: 50.0, started_at_unix: 0, completed_at_unix: 0, @@ -14,9 +14,8 @@ fn sample(status: PrintJobStatus) -> PrintJob { } } -// Regression test: PrintJob.status must serialize as the full proto enum -// name (matching ts-types' TypeBox schema), not the raw i32 prost stores -// it as internally. +// Regression test: PrintJob.status must serialize as the full name string +// (matching ts-types' TypeBox schema), not a bare enum discriminant. #[test] fn status_serializes_as_full_proto_enum_name() { let json = serde_json::to_value(sample(PrintJobStatus::Printing)).unwrap(); @@ -28,5 +27,5 @@ fn status_round_trips_through_json() { let original = sample(PrintJobStatus::Completed); let json = serde_json::to_string(&original).unwrap(); let restored: PrintJob = serde_json::from_str(&json).unwrap(); - assert_eq!(restored.status, PrintJobStatus::Completed as i32); + assert_eq!(restored.status, PrintJobStatus::Completed); } diff --git a/packages/ts-types/package.json b/packages/ts-types/package.json index dc2d641..9375436 100644 --- a/packages/ts-types/package.json +++ b/packages/ts-types/package.json @@ -9,8 +9,6 @@ ".": "./src/index.ts" }, "dependencies": { - "@sinclair/typebox": "^0.32.0", - "protobufjs": "^7.4.0", - "long": "^5.2.3" + "@sinclair/typebox": "^0.32.0" } } diff --git a/packages/ts-types/src/generated/.gitkeep b/packages/ts-types/src/generated/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/ts-types/src/index.d.ts b/packages/ts-types/src/index.d.ts index ada6fe7..292f605 100644 --- a/packages/ts-types/src/index.d.ts +++ b/packages/ts-types/src/index.d.ts @@ -1,2 +1 @@ export * from "./schemas"; -export * as proto from "./generated"; diff --git a/packages/ts-types/src/index.js b/packages/ts-types/src/index.js index e22f64b..292f605 100644 --- a/packages/ts-types/src/index.js +++ b/packages/ts-types/src/index.js @@ -1,6 +1 @@ export * from "./schemas"; -// `src/generated/**` is produced by `npm run build:ts` (protoc + ts-proto) -// from the .proto sources in /proto and is not committed. Re-export it -// here once generated so consumers get both the TypeBox runtime schemas -// and the proto-derived TS types from a single package entry point. -export * as proto from "./generated"; diff --git a/packages/ts-types/src/index.ts b/packages/ts-types/src/index.ts index 9721a86..292f605 100644 --- a/packages/ts-types/src/index.ts +++ b/packages/ts-types/src/index.ts @@ -1,7 +1 @@ export * from "./schemas"; - -// `src/generated/**` is produced by `npm run build:ts` (protoc + ts-proto) -// from the .proto sources in /proto and is not committed. Re-export it -// here once generated so consumers get both the TypeBox runtime schemas -// and the proto-derived TS types from a single package entry point. -export * as proto from "./generated"; diff --git a/proto/continuum/v1/material.proto b/proto/continuum/v1/material.proto deleted file mode 100644 index 138fe8f..0000000 --- a/proto/continuum/v1/material.proto +++ /dev/null @@ -1,13 +0,0 @@ -syntax = "proto3"; - -package continuum.v1; - -message MaterialProfile { - string id = 1; - string name = 2; - string filament_type = 3; - double nozzle_temp_c = 4; - double bed_temp_c = 5; - double density_g_cm3 = 6; - string color_hex = 7; -} diff --git a/proto/continuum/v1/print_job.proto b/proto/continuum/v1/print_job.proto deleted file mode 100644 index 0659602..0000000 --- a/proto/continuum/v1/print_job.proto +++ /dev/null @@ -1,25 +0,0 @@ -syntax = "proto3"; - -package continuum.v1; - -enum PrintJobStatus { - PRINT_JOB_STATUS_UNSPECIFIED = 0; - PRINT_JOB_STATUS_QUEUED = 1; - PRINT_JOB_STATUS_PRINTING = 2; - PRINT_JOB_STATUS_PAUSED = 3; - PRINT_JOB_STATUS_COMPLETED = 4; - PRINT_JOB_STATUS_FAILED = 5; - PRINT_JOB_STATUS_CANCELLED = 6; -} - -message PrintJob { - string id = 1; - string farm_id = 2; - string printer_id = 3; - string template_id = 4; - PrintJobStatus status = 5; - double progress_percent = 6; - int64 started_at_unix = 7; - int64 completed_at_unix = 8; - string gcode_file_key = 9; -} diff --git a/proto/continuum/v1/print_params.proto b/proto/continuum/v1/print_params.proto deleted file mode 100644 index f0f8ac3..0000000 --- a/proto/continuum/v1/print_params.proto +++ /dev/null @@ -1,12 +0,0 @@ -syntax = "proto3"; - -package continuum.v1; - -message PrintParameters { - double layer_height_mm = 1; - int32 infill_percent = 2; - string infill_pattern = 3; - double print_speed_mm_s = 4; - bool supports_enabled = 5; - int32 wall_count = 6; -} diff --git a/proto/continuum/v1/template.proto b/proto/continuum/v1/template.proto deleted file mode 100644 index 116071f..0000000 --- a/proto/continuum/v1/template.proto +++ /dev/null @@ -1,19 +0,0 @@ -syntax = "proto3"; - -package continuum.v1; - -import "continuum/v1/material.proto"; -import "continuum/v1/print_params.proto"; - -message Template { - string id = 1; - string name = 2; - string description = 3; - string owner_id = 4; - string model_file_key = 5; - MaterialProfile material = 6; - PrintParameters print_parameters = 7; - int64 created_at_unix = 8; - int64 updated_at_unix = 9; - repeated string tags = 10; -} diff --git a/schemas/continuum/v1/material.schema.json b/schemas/continuum/v1/material.schema.json new file mode 100644 index 0000000..32f79aa --- /dev/null +++ b/schemas/continuum/v1/material.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/material.schema.json", + "title": "MaterialProfile", + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "minLength": 1 }, + "filamentType": { "type": "string", "minLength": 1 }, + "nozzleTempC": { "type": "number" }, + "bedTempC": { "type": "number" }, + "densityGCm3": { "type": "number" }, + "colorHex": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" } + }, + "required": [ + "id", + "name", + "filamentType", + "nozzleTempC", + "bedTempC", + "densityGCm3", + "colorHex" + ], + "additionalProperties": false +} diff --git a/schemas/continuum/v1/print_job.schema.json b/schemas/continuum/v1/print_job.schema.json new file mode 100644 index 0000000..d4b542d --- /dev/null +++ b/schemas/continuum/v1/print_job.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/print_job.schema.json", + "title": "PrintJob", + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "farmId": { "type": "string", "format": "uuid" }, + "printerId": { "type": "string", "format": "uuid" }, + "templateId": { "type": "string", "format": "uuid" }, + "status": { + "type": "string", + "enum": [ + "PRINT_JOB_STATUS_UNSPECIFIED", + "PRINT_JOB_STATUS_QUEUED", + "PRINT_JOB_STATUS_PRINTING", + "PRINT_JOB_STATUS_PAUSED", + "PRINT_JOB_STATUS_COMPLETED", + "PRINT_JOB_STATUS_FAILED", + "PRINT_JOB_STATUS_CANCELLED" + ] + }, + "progressPercent": { "type": "number", "minimum": 0, "maximum": 100 }, + "startedAtUnix": { "type": "integer" }, + "completedAtUnix": { "type": "integer" }, + "gcodeFileKey": { "type": "string", "minLength": 1 } + }, + "required": [ + "id", + "farmId", + "printerId", + "templateId", + "status", + "progressPercent", + "startedAtUnix", + "completedAtUnix", + "gcodeFileKey" + ], + "additionalProperties": false +} diff --git a/schemas/continuum/v1/print_params.schema.json b/schemas/continuum/v1/print_params.schema.json new file mode 100644 index 0000000..41183ce --- /dev/null +++ b/schemas/continuum/v1/print_params.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/print_params.schema.json", + "title": "PrintParameters", + "type": "object", + "properties": { + "layerHeightMm": { "type": "number", "minimum": 0.04, "maximum": 1.0 }, + "infillPercent": { "type": "integer", "minimum": 0, "maximum": 100 }, + "infillPattern": { "type": "string", "minLength": 1 }, + "printSpeedMmS": { "type": "number", "minimum": 0 }, + "supportsEnabled": { "type": "boolean" }, + "wallCount": { "type": "integer", "minimum": 0 } + }, + "required": [ + "layerHeightMm", + "infillPercent", + "infillPattern", + "printSpeedMmS", + "supportsEnabled", + "wallCount" + ], + "additionalProperties": false +} diff --git a/schemas/continuum/v1/template.schema.json b/schemas/continuum/v1/template.schema.json new file mode 100644 index 0000000..196fa92 --- /dev/null +++ b/schemas/continuum/v1/template.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/template.schema.json", + "title": "Template", + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "ownerId": { "type": "string", "format": "uuid" }, + "modelFileKey": { "type": "string", "minLength": 1 }, + "material": { "$ref": "material.schema.json" }, + "printParameters": { "$ref": "print_params.schema.json" }, + "createdAtUnix": { "type": "integer" }, + "updatedAtUnix": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "required": [ + "id", + "name", + "description", + "ownerId", + "modelFileKey", + "material", + "printParameters", + "createdAtUnix", + "updatedAtUnix", + "tags" + ], + "additionalProperties": false +}