From e4fb45ac83cc380dcb92ac12740d932627f0f34b Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:53:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?spike:=20spock=20gen=20provider=20=E2=80=94?= =?UTF-8?q?=20assembly-layer=20tables=20from=20the=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence spike for gridaco/uhura#29, in the home RFD 0010 assigns to generation: the binary. A new gen subcommand consumes the compiled contract (typed, in-process — no JSON parsing seam) plus a per-app assembly declaration, validates every referenced table/fn/error against the contract, and emits the provider tables module: snapshot GraphQL document, machine contract types, dispatch switch, refusal whitelist, mutation routing, and the Play asset table. Nine golden tests hold each artifact char-equal with the hand-written instagram provider it replaces; declaration syntax is a spike and not proposed for adoption. --- Cargo.lock | 3 + crates/spock-cli/Cargo.toml | 3 + crates/spock-cli/src/lib.rs | 1 + crates/spock-cli/src/main.rs | 19 +- crates/spock-cli/src/provider_gen/mod.rs | 1287 ++++++ crates/spock-cli/src/provider_gen/wire.pest | 59 + .../tests/provider_fixtures/contract.json | 3842 +++++++++++++++++ .../provider_fixtures/dispatch-switch.ts | 70 + .../tests/provider_fixtures/instagram.wire | 90 + .../provider_fixtures/machine-types.uhura | 44 + .../tests/provider_fixtures/manifest.toml | 531 +++ .../tests/provider_fixtures/play-assets.ts | 39 + .../provider_fixtures/snapshot-query.graphql | 69 + .../tests/provider_fixtures/spock-provider.ts | 2089 +++++++++ .../tests/provider_fixtures/view-types.uhura | 37 + crates/spock-cli/tests/provider_gen.rs | 121 + 16 files changed, 8301 insertions(+), 3 deletions(-) create mode 100644 crates/spock-cli/src/provider_gen/mod.rs create mode 100644 crates/spock-cli/src/provider_gen/wire.pest create mode 100644 crates/spock-cli/tests/provider_fixtures/contract.json create mode 100644 crates/spock-cli/tests/provider_fixtures/dispatch-switch.ts create mode 100644 crates/spock-cli/tests/provider_fixtures/instagram.wire create mode 100644 crates/spock-cli/tests/provider_fixtures/machine-types.uhura create mode 100644 crates/spock-cli/tests/provider_fixtures/manifest.toml create mode 100644 crates/spock-cli/tests/provider_fixtures/play-assets.ts create mode 100644 crates/spock-cli/tests/provider_fixtures/snapshot-query.graphql create mode 100644 crates/spock-cli/tests/provider_fixtures/spock-provider.ts create mode 100644 crates/spock-cli/tests/provider_fixtures/view-types.uhura create mode 100644 crates/spock-cli/tests/provider_gen.rs diff --git a/Cargo.lock b/Cargo.lock index cfa4ad4..fa9b08c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2133,9 +2133,12 @@ dependencies = [ "cap-std", "clap", "fs_at", + "pest", + "pest_derive", "predicates", "rustix", "same-file", + "serde", "serde_json", "spock-host", "spock-lang", diff --git a/crates/spock-cli/Cargo.toml b/crates/spock-cli/Cargo.toml index abf3248..8e715e0 100644 --- a/crates/spock-cli/Cargo.toml +++ b/crates/spock-cli/Cargo.toml @@ -14,10 +14,13 @@ name = "spock" path = "src/main.rs" [dependencies] +pest = "2" +pest_derive = "2" spock-lang = { path = "../spock-lang" } spock-runtime = { path = "../spock-runtime" } spock-project = { path = "../spock-project" } spock-host = { path = "../spock-host" } +serde.workspace = true serde_json.workspace = true clap.workspace = true anyhow.workspace = true diff --git a/crates/spock-cli/src/lib.rs b/crates/spock-cli/src/lib.rs index d5248f6..b6bcf40 100644 --- a/crates/spock-cli/src/lib.rs +++ b/crates/spock-cli/src/lib.rs @@ -489,3 +489,4 @@ mod tests { drop(released); } } +pub mod provider_gen; diff --git a/crates/spock-cli/src/main.rs b/crates/spock-cli/src/main.rs index 1d75958..7d44fae 100644 --- a/crates/spock-cli/src/main.rs +++ b/crates/spock-cli/src/main.rs @@ -103,6 +103,15 @@ enum GenTarget { #[arg(short, long)] out: Option, }, + /// SPIKE (uhura#29): provider tables from an app declaration + the contract. + Provider { + file: PathBuf, + /// The app assembly declaration (.wire spike syntax). + #[arg(long)] + app: PathBuf, + #[arg(short, long)] + out: Option, + }, } fn main() -> ExitCode { @@ -194,9 +203,9 @@ fn execute(command: Command) -> ExitCode { } Command::Gen { target } => { let (file, out) = match &target { - GenTarget::Types { file, out } | GenTarget::GraphqlSchema { file, out } => { - (file.clone(), out.clone()) - } + GenTarget::Types { file, out } + | GenTarget::GraphqlSchema { file, out } + | GenTarget::Provider { file, out, .. } => (file.clone(), out.clone()), }; let Some(program) = load_or_report(&file) else { return ExitCode::FAILURE; @@ -209,6 +218,10 @@ fn execute(command: Command) -> ExitCode { program.contract(), GenerationTarget::GraphqlSchema, ), + GenTarget::Provider { app, .. } => { + spock_cli::provider_gen::generate_from_contract(program.contract(), &app) + .map_err(anyhow::Error::msg) + } }; match artifact { Ok(content) => emit(out, content), diff --git a/crates/spock-cli/src/provider_gen/mod.rs b/crates/spock-cli/src/provider_gen/mod.rs new file mode 100644 index 0000000..72c952f --- /dev/null +++ b/crates/spock-cli/src/provider_gen/mod.rs @@ -0,0 +1,1287 @@ +//! Provider generation spike (uhura#29): .wire v0.1 — Spock 스키마 위의 투영·계약 언어. +//! 이 크레이트는 v0.1 범위만 구현한다: .wire 파일을 파싱해 기계 측 +//! 계약 타입(Mutation / Settlement)을 Uhura 0.4 선언문으로 생성한다. +//! 뷰 투영·어댑터 생성은 v0.2 (docs/03 참조). + +use pest::Parser; +use pest_derive::Parser; +use serde_json::Value; + +#[derive(Parser)] +#[grammar = "provider_gen/wire.pest"] +struct WireParser; + +#[derive(Debug, PartialEq)] +pub struct Field { + pub name: String, + /// `.wire` 원문 타입 표기 (예: `post.id`) — 스키마 대조에 쓴다. + pub source: String, + pub ty: String, +} + +#[derive(Debug, PartialEq)] +pub struct MutationDecl { + pub name: String, + /// 백엔드 연산 kind 오버라이드 (`op choose_image_request`); 기본은 snake_case(name) + pub op: Option, + pub fields: Vec, + pub policy: String, +} + +#[derive(Debug, PartialEq)] +pub struct CallSpec { + pub fn_name: String, + /// `if ` 분기의 (플래그 필드, 이 호출이 담당하는 값) + pub when: Option<(String, bool)>, + /// 호출 인자 이름들 — 연산 객체에서 뽑아 RPC 본문이 된다 + pub args: Vec, + /// 거절 화이트리스트의 라우트 키 (`route feed/like-post`) — 명시 선언, 파생 없음 + pub route: Option, + pub allows: Vec, +} + +#[derive(Debug, PartialEq, Default)] +pub struct SettlementDecl { + pub extras: Vec<(String, Vec)>, +} + +#[derive(Debug, PartialEq)] +pub enum ViewEntry { + /// 원본 컬럼 그대로, 선택적 투영(`author -> User`). + Column { name: String, projected: Option }, + /// `x = ago(col)` → Text + Ago { name: String, column: String }, + /// `x = count where …` → Nat + Count { name: String, table: String }, + /// `x = exists
where …` → Bool + Exists { name: String, table: String }, + /// `x = match { … }` → PascalCase(x) 합타입 (팔에서 몸체 생성) + Match { + name: String, + column: String, + arms: Vec, + }, + /// `x = tiles of
…` → Seq + Tiles { name: String, table: String }, + /// `x = row as T` → T (행 자체의 투영) + RowAs { name: String, ty: String }, +} + +#[derive(Debug, PartialEq)] +pub struct MatchArm { + pub tag: String, + pub variant: String, + pub fields: Vec<(String, VariantField)>, +} + +#[derive(Debug, PartialEq)] +pub enum VariantField { + /// `as ` — 뷰 원본 테이블의 컬럼을 자산 클래스로 투영 + Scalar { column: String, class: String }, + /// `each
.as …` — 하위 행들의 시퀀스 투영 + Each { + table: String, + column: String, + class: String, + }, +} + +#[derive(Debug, PartialEq)] +pub struct ViewDecl { + pub name: String, + pub source: String, + pub entries: Vec, +} + +#[derive(Debug, PartialEq)] +pub struct SnapshotRead { + pub table: String, + /// `read carousel_slide as slides` — 불규칙 별칭의 명시 오버라이드. + pub alias: Option, +} + +#[derive(Debug, Default)] +pub struct WireFile { + pub app: Option, + /// fixtures 블록의 명시 비디오 매핑 (매니페스트에 파생원 없음) + pub videos: Vec<(String, String)>, + pub snapshot_cap: Option, + pub snapshot_reads: Vec, + pub views: Vec, + pub mutations: Vec, + pub settlement: SettlementDecl, +} + +#[derive(Debug)] +pub struct ParseError(pub String); + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl std::error::Error for ParseError {} + +/// `.wire` 필드 타입 → 기계 측 타입 이름. +/// `
.id`는 `
Id`로 투영되고, 스칼라는 0.4 프렐류드 이름을 쓴다. +fn machine_type(ty: &str) -> Result { + match ty { + "text" => Ok("Text".to_string()), + "bool" => Ok("Bool".to_string()), + "int" => Ok("Int".to_string()), + "nat" => Ok("Nat".to_string()), + other => match other.split_once('.') { + Some((table, "id")) => { + let mut chars = table.chars(); + let head = chars + .next() + .ok_or_else(|| ParseError("empty table name".into()))?; + Ok(format!("{}{}Id", head.to_ascii_uppercase(), chars.as_str())) + } + _ => Err(ParseError(format!("unknown field type `{other}`"))), + }, + } +} + +fn collect_fields(pair: pest::iterators::Pair) -> Result, ParseError> { + let mut out = Vec::new(); + for field in pair.into_inner() { + let mut inner = field.into_inner(); + let name = inner.next().expect("field name").as_str().to_string(); + let source = inner.next().expect("field type").as_str().to_string(); + let ty = machine_type(&source)?; + out.push(Field { name, source, ty }); + } + Ok(out) +} + +fn parse_arm(pair: pest::iterators::Pair) -> Result { + let mut inner = pair.into_inner(); + let tag = inner + .next() + .expect("arm tag") + .as_str() + .trim_matches('"') + .to_string(); + let variant = inner.next().expect("arm variant").as_str().to_string(); + let mut fields = Vec::new(); + for vfield in inner { + let mut parts = vfield.into_inner(); + let name = parts.next().expect("variant field name").as_str().to_string(); + let expr = parts + .next() + .expect("variant field expr") + .into_inner() + .next() + .expect("vfexpr variant"); + let rule = expr.as_rule(); + let mut ops = expr.into_inner(); + let field = match rule { + Rule::asof_e => VariantField::Scalar { + column: ops.next().expect("column").as_str().to_string(), + class: ops.next().expect("class").as_str().to_string(), + }, + Rule::each_e => VariantField::Each { + table: ops.next().expect("table").as_str().to_string(), + column: ops.next().expect("column").as_str().to_string(), + class: ops.next().expect("class").as_str().to_string(), + }, + other => return Err(ParseError(format!("unexpected variant field {other:?}"))), + }; + fields.push((name, field)); + } + Ok(MatchArm { + tag, + variant, + fields, + }) +} + +/// 자산 클래스 → 기계 타입. storage_object 컬럼만 자산 투영이 가능하다. +fn asset_type(class: &str) -> Option<&'static str> { + match class { + "image" => Some("ImageRef"), + "url" => Some("Text"), + _ => None, + } +} + +pub fn parse(source: &str) -> Result { + let mut pairs = + WireParser::parse(Rule::file, source).map_err(|e| ParseError(e.to_string()))?; + let file = pairs.next().expect("file rule"); + let mut out = WireFile::default(); + + for item in file.into_inner() { + match item.as_rule() { + Rule::snapshot => { + for entry in item.into_inner() { + match entry.as_rule() { + Rule::cap_decl => { + let n = entry.into_inner().next().expect("cap number"); + out.snapshot_cap = n.as_str().parse().ok(); + } + Rule::read_decl => { + for read in entry.into_inner() { + let mut parts = read.into_inner(); + let table = + parts.next().expect("read table").as_str().to_string(); + let alias = parts.next().map(|p| p.as_str().to_string()); + out.snapshot_reads.push(SnapshotRead { table, alias }); + } + } + _ => {} + } + } + } + Rule::view => { + let mut inner = item.into_inner(); + let name = inner.next().expect("view name").as_str().to_string(); + let source = inner.next().expect("view source").as_str().to_string(); + let mut entries = Vec::new(); + for entry in inner { + match entry.as_rule() { + Rule::column => { + let mut parts = entry.into_inner(); + let name = parts.next().expect("column name").as_str().to_string(); + let projected = parts.next().map(|p| p.as_str().to_string()); + entries.push(ViewEntry::Column { name, projected }); + } + Rule::computed => { + let mut parts = entry.into_inner(); + let name = parts.next().expect("computed name").as_str().to_string(); + let expr = parts + .next() + .expect("computed expr") + .into_inner() + .next() + .expect("vexpr variant"); + let rule = expr.as_rule(); + if rule == Rule::match_e { + let mut inner = expr.into_inner(); + let column = + inner.next().expect("match column").as_str().to_string(); + let arms = inner.map(parse_arm).collect::>()?; + entries.push(ViewEntry::Match { name, column, arms }); + continue; + } + let first = expr + .into_inner() + .next() + .expect("expr operand") + .as_str() + .to_string(); + entries.push(match rule { + Rule::ago_e => ViewEntry::Ago { name, column: first }, + Rule::count_e => ViewEntry::Count { name, table: first }, + Rule::exists_e => ViewEntry::Exists { name, table: first }, + Rule::tiles_e => ViewEntry::Tiles { name, table: first }, + Rule::rowas_e => ViewEntry::RowAs { name, ty: first }, + other => { + return Err(ParseError(format!( + "unexpected view expression {other:?}" + ))) + } + }); + } + _ => {} + } + } + out.views.push(ViewDecl { + name, + source, + entries, + }); + } + Rule::fixtures => { + for entry in item.into_inner() { + if entry.as_rule() == Rule::video_decl { + let mut parts = entry.into_inner(); + let name = parts + .next() + .expect("video name") + .as_str() + .trim_matches('"') + .to_string(); + let file = parts + .next() + .expect("video file") + .as_str() + .trim_matches('"') + .to_string(); + out.videos.push((name, file)); + } + } + } + Rule::app_decl => { + let s = item.into_inner().next().expect("app name"); + out.app = Some(s.as_str().trim_matches('"').to_string()); + } + Rule::mutation => { + let mut inner = item.into_inner(); + let name = inner.next().expect("mutation name").as_str().to_string(); + let mut op = None; + let mut fields = Vec::new(); + let mut policy = String::new(); + for part in inner { + match part.as_rule() { + Rule::ident => op = Some(part.as_str().to_string()), + Rule::fields => fields = collect_fields(part)?, + Rule::policy => { + policy = part.as_str().trim_start_matches("->").trim().to_string() + } + _ => {} + } + } + out.mutations.push(MutationDecl { + name, + op, + fields, + policy, + }); + } + Rule::settlement => { + for entry in item.into_inner() { + if entry.as_rule() == Rule::extra_decl { + let mut inner = entry.into_inner(); + let name = inner.next().expect("extra name").as_str().to_string(); + let fields = collect_fields(inner.next().expect("extra fields"))?; + out.settlement.extras.push((name, fields)); + } + } + } + _ => {} + } + } + Ok(out) +} + +/// 정책 문자열에서 `call (...) [route g/name] allow e1, e2` 절을 추출한다. +/// `local {...}` / `host {...}` 정책은 호출이 없으므로 빈 목록. +pub fn policy_calls(policy: &str) -> Vec { + let mut out = Vec::new(); + // `if call A(...) ... else call B(...)` — 첫 호출=true, 둘째=false + let flag = policy.trim_start().strip_prefix("if ").map(|rest| { + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + rest[..end].to_string() + }); + let mut call_index = 0usize; + let mut rest = policy; + while let Some(pos) = rest.find("call ") { + // "call"이 식별자 일부가 아니어야 한다 + if pos > 0 + && rest[..pos] + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') + { + rest = &rest[pos + 5..]; + continue; + } + rest = &rest[pos + 5..]; + let name_end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let fn_name = rest[..name_end].to_string(); + rest = &rest[name_end..]; + let mut args = Vec::new(); + if let Some(open) = rest.find('(') { + if let Some(close) = rest.find(')') { + args = rest[open + 1..close] + .split(',') + .map(|a| a.trim().to_string()) + .filter(|a| !a.is_empty()) + .collect(); + rest = &rest[close + 1..]; + } + } + let mut route = None; + let trimmed = rest.trim_start(); + if let Some(after) = trimmed.strip_prefix("route ") { + let token_end = after + .find(char::is_whitespace) + .unwrap_or(after.len()); + route = Some(after[..token_end].to_string()); + rest = &after[token_end..]; + } + let mut allows = Vec::new(); + let trimmed = rest.trim_start(); + if let Some(list) = trimmed.strip_prefix("allow ") { + let mut consumed = rest.len() - trimmed.len() + 6; + for word in list.split_whitespace() { + if word == "else" { + break; + } + let had_comma = word.ends_with(','); + let ident = word.trim_end_matches(','); + consumed += word.len() + 1; + if !ident.is_empty() { + allows.push(ident.to_string()); + } + if !had_comma { + break; + } + } + rest = &rest[consumed.min(rest.len())..]; + } + if !fn_name.is_empty() { + let when = flag + .as_ref() + .map(|f| (f.clone(), call_index == 0)); + call_index += 1; + out.push(CallSpec { + fn_name, + when, + args, + route, + allows, + }); + } + } + out +} + +/// 뮤테이션 → 백엔드 라우팅 표 (JSON). 런타임이 이 표만 보고 +/// 분기·RPC 인자·거절 라우트를 결정한다 — 로직은 소유하지 않는다. +pub fn generate_routing(file: &WireFile) -> String { + let mut out = String::from("{\n"); + for (i, m) in file.mutations.iter().enumerate() { + let kind = m.op.clone().unwrap_or_else(|| snake(&m.name)); + let policy = m.policy.trim_start(); + let mode = if policy.starts_with("local") { + "local" + } else if policy.starts_with("host") { + "host" + } else { + "call" + }; + out.push_str(&format!( + " \"{}\": {{ \"kind\": \"{kind}\", \"mode\": \"{mode}\"", + m.name + )); + let calls = policy_calls(&m.policy); + if let Some((flag, _)) = calls.first().and_then(|c| c.when.clone()) { + out.push_str(&format!(", \"flag\": \"{flag}\"")); + } + if !calls.is_empty() { + out.push_str(", \"calls\": ["); + for (j, c) in calls.iter().enumerate() { + if j > 0 { + out.push_str(", "); + } + let when = match &c.when { + Some((_, v)) => v.to_string(), + None => "null".to_string(), + }; + let args = c + .args + .iter() + .map(|a| format!("\"{a}\"")) + .collect::>() + .join(", "); + out.push_str(&format!( + "{{ \"when\": {when}, \"fn\": \"{}\", \"route\": {}, \"args\": [{args}] }}", + c.fn_name, + match &c.route { + Some(r) => format!("\"{r}\""), + None => "null".to_string(), + } + )); + } + out.push(']'); + } + out.push_str(" }"); + if i + 1 < file.mutations.len() { + out.push(','); + } + out.push('\n'); + } + out.push('}'); + out +} + +/// 스키마 대조: .wire가 참조하는 테이블·fn·에러가 계약에 실존하는지. +/// 위반은 사람이 읽을 수 있는 문장 목록으로 돌려준다 (조용한 통과 금지). +pub fn validate_against(file: &WireFile, schema: &SpockSchema) -> Vec { + let mut problems = Vec::new(); + for read in &file.snapshot_reads { + if !schema.has_table(&read.table) { + problems.push(format!("snapshot reads unknown table `{}`", read.table)); + } + } + for m in &file.mutations { + for f in &m.fields { + if let Some((table, "id")) = f.source.split_once('.') { + if !schema.has_table(table) { + problems.push(format!( + "{}.{} references unknown table `{table}`", + m.name, f.name + )); + } + } + } + for call in policy_calls(&m.policy) { + let fn_name = &call.fn_name; + match schema.find_fn(fn_name) { + None => problems.push(format!("{} calls unknown fn `{fn_name}`", m.name)), + Some(spock_fn) => { + for allowed in &call.allows { + if !spock_fn.errors.iter().any(|e| e == allowed) { + problems.push(format!( + "{} allows `{allowed}` but `{fn_name}` declares only {:?}", + m.name, spock_fn.errors + )); + } + } + } + } + if !call.allows.is_empty() && call.route.is_none() { + problems.push(format!( + "{} call `{fn_name}` has an allow list but no `route`", + m.name + )); + } + } + } + problems +} + +/// 스칼라 컬럼 타입 → 기계 타입. FK/enum은 여기 오지 않는다. +fn column_machine_type(table: &str, column: &SpockColumn) -> Option { + if column.key && column.base == "uuid" { + let mut chars = table.chars(); + let head = chars.next()?; + return Some(format!("{}{}Id", head.to_ascii_uppercase(), chars.as_str())); + } + match column.base.as_str() { + "text" => Some("Text".to_string()), + "bool" => Some("Bool".to_string()), + "int" => Some("Int".to_string()), + _ => None, + } +} + +fn snake(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out +} + +fn kebab(name: &str) -> String { + name.replace('_', "-") +} + +fn screaming(name: &str) -> String { + name.to_ascii_uppercase() +} + +/// 뮤테이션 표면 → 어댑터의 `toBackendOperation` 스위치 본문 (TS 텍스트). +/// kind = `op` 오버라이드 또는 snake_case(이름); 필드 변환은 타입에서 유도. +pub fn generate_dispatch(file: &WireFile) -> Result> { + let mut problems = Vec::new(); + let Some(app) = &file.app else { + return Err(vec!["missing `app \"Name\";` declaration".to_string()]); + }; + let mut out = String::new(); + for m in &file.mutations { + let kind = m.op.clone().unwrap_or_else(|| snake(&m.name)); + out.push_str(&format!(" case \"{}\":\n", m.name)); + if m.fields.is_empty() { + out.push_str(&format!( + " return {{ request, operation: {{ kind: \"{kind}\" }} }};\n" + )); + continue; + } + out.push_str(" return {\n request,\n operation: {\n"); + out.push_str(&format!(" kind: \"{kind}\",\n")); + for f in &m.fields { + let conv = match f.source.split_once('.') { + Some((table, "id")) => format!( + "keyText(requiredField(fields, \"{}\"), {}_ID_TYPE)", + f.name, + screaming(table) + ), + _ => match f.source.as_str() { + "bool" => format!("boolValue(requiredField(fields, \"{}\"))", f.name), + "text" => format!("textValue(requiredField(fields, \"{}\"))", f.name), + other => { + problems.push(format!( + "{}.{}: no dispatch conversion for `{other}`", + m.name, f.name + )); + continue; + } + }, + }; + out.push_str(&format!(" {}: {conv},\n", f.name)); + } + out.push_str(" },\n };\n"); + } + out.push_str(&format!( + " default:\n throw new TypeError(`unsupported {app} mutation \\`${{mutation}}\\``);\n" + )); + if problems.is_empty() { + Ok(out) + } else { + Err(problems) + } +} + +/// 라우트 키 → kebab 거절 목록. 중복 라우트는 에러. +pub fn generate_refusals(file: &WireFile) -> Result)>, Vec> { + let mut problems = Vec::new(); + let mut out: Vec<(String, Vec)> = Vec::new(); + for m in &file.mutations { + for call in policy_calls(&m.policy) { + let Some(route) = call.route else { continue }; + if out.iter().any(|(r, _)| r == &route) { + problems.push(format!("duplicate route `{route}`")); + continue; + } + out.push((route, call.allows.iter().map(|a| kebab(a)).collect())); + } + } + if problems.is_empty() { + Ok(out) + } else { + Err(problems) + } +} + +/// S1+S2 산출물을 실행 가능한 provider 테이블 모듈(ESM JS)로 조립한다. +/// 로직(큐·정산)은 공유 런타임 몫이고, 이 모듈은 데이터와 순수 디스패치만 담는다. +pub fn generate_provider_module( + file: &WireFile, + schema: &SpockSchema, +) -> Result> { + let snapshot = generate_snapshot_query(file, schema)?; + let dispatch = generate_dispatch(file)?; + let refusals = generate_refusals(file)?; + + let mut id_types: Vec = Vec::new(); + for m in &file.mutations { + for f in &m.fields { + if let Some((table, "id")) = f.source.split_once('.') { + let name = format!("{}_ID_TYPE", screaming(table)); + if !id_types.contains(&name) { + id_types.push(name); + } + } + } + } + + let routing = generate_routing(file); + let mut out = String::from("// Generated by `spock gen provider` — do not edit.\n\n"); + out.push_str(&format!("export const SNAPSHOT_QUERY = `{snapshot}`;\n\n")); + out.push_str(&format!("export const MUTATION_ROUTING = {routing};\n\n")); + out.push_str("export const COMMAND_REFUSALS = {\n"); + for (route, allows) in &refusals { + let list = allows + .iter() + .map(|a| format!("\"{a}\"")) + .collect::>() + .join(", "); + out.push_str(&format!(" \"{route}\": [{list}],\n")); + } + out.push_str("};\n\n"); + let mut helper_names = vec![ + "keyText".to_string(), + "requiredField".to_string(), + "boolValue".to_string(), + "textValue".to_string(), + ]; + helper_names.extend(id_types); + out.push_str(&format!( + "export function toBackendOperation(mutation, request, fields, helpers) {{\n const {{ {} }} = helpers;\n switch (mutation) {{\n{dispatch} }}\n}}\n", + helper_names.join(", ") + )); + Ok(out) +} + +/// 자산 매니페스트(manifest.toml)의 `[assets.]` + `file = "..."` 쌍을 +/// 추출한다. alt/size/sha256 등은 gen-assets 도구 소유라 여기서 읽지 않는다. +pub fn parse_manifest(source: &str) -> Result, Vec> { + let mut problems = Vec::new(); + let mut out: Vec<(String, String)> = Vec::new(); + let mut current: Option = None; + for line in source.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("[assets.") { + if let Some(prev) = current.take() { + problems.push(format!("asset `{prev}` has no `file` entry")); + } + let name = rest.trim_end_matches(']').to_string(); + if out.iter().any(|(n, _)| n == &name) { + problems.push(format!("duplicate asset `{name}`")); + continue; + } + current = Some(name); + } else if let Some(rest) = line.strip_prefix("file = ") { + if let Some(name) = current.take() { + out.push((name, rest.trim_matches('"').to_string())); + } + } + } + if let Some(prev) = current { + problems.push(format!("asset `{prev}` has no `file` entry")); + } + if problems.is_empty() { + Ok(out) + } else { + Err(problems) + } +} + +/// 매니페스트 항목 + .wire의 명시 비디오 매핑 → Play 자산 논리명 표 (TS 텍스트). +/// 이름 충돌은 에러. +pub fn generate_play_assets( + entries: &[(String, String)], + videos: &[(String, String)], +) -> Result> { + let mut problems = Vec::new(); + for (name, _) in videos { + if entries.iter().any(|(n, _)| n == name) { + problems.push(format!("video `{name}` collides with a manifest asset")); + } + } + if !problems.is_empty() { + return Err(problems); + } + let mut out = + String::from("const LOCAL_PLAY_ASSETS: Readonly> = {\n"); + for (name, file) in entries.iter().chain(videos) { + out.push_str(&format!(" \"{name}\": \"{file}\",\n")); + } + out.push_str("};"); + Ok(out) +} + +fn camel(name: &str) -> String { + let mut parts = name.split('_').filter(|s| !s.is_empty()); + let mut out = parts.next().unwrap_or_default().to_string(); + for part in parts { + let mut chars = part.chars(); + if let Some(head) = chars.next() { + out.push(head.to_ascii_uppercase()); + out.push_str(chars.as_str()); + } + } + out +} + +fn pluralize(name: &str) -> String { + match name.strip_suffix('y') { + Some(stem) => format!("{stem}ies"), + None => format!("{name}s"), + } +} + +/// 스냅샷 항목의 기본 별칭: camelCase 복수형 (user→users, story→stories, +/// story_view→storyViews). 이 규칙을 벗어나는 별칭은 `as`로 명시해야 한다. +pub fn default_alias(table: &str) -> String { + pluralize(&camel(table)) +} + +/// .wire 스냅샷 선언 + 스키마 컬럼 → 어댑터의 GraphQL 스냅샷 문서. +/// FK와 storage_object 컬럼은 `name { id }`로, 나머지는 bare로 투영된다. +pub fn generate_snapshot_query( + file: &WireFile, + schema: &SpockSchema, +) -> Result> { + let mut problems = Vec::new(); + let Some(cap) = file.snapshot_cap else { + return Err(vec!["snapshot has no `cap N per table` declaration".to_string()]); + }; + let mut out = String::from("\n query UhuraSnapshot {\n"); + for read in &file.snapshot_reads { + let Some(table) = schema.find_table(&read.table) else { + problems.push(format!("snapshot reads unknown table `{}`", read.table)); + continue; + }; + let alias = read + .alias + .clone() + .unwrap_or_else(|| default_alias(&read.table)); + out.push_str(&format!(" {alias}: {}(limit: {cap}) {{\n", read.table)); + for column in &table.columns { + let is_ref = + column.base == "storage_object" || schema.has_table(&column.base); + if is_ref { + out.push_str(&format!(" {} {{ id }}\n", column.name)); + } else { + out.push_str(&format!(" {}\n", column.name)); + } + } + out.push_str(" }\n"); + } + out.push_str(" }\n"); + if problems.is_empty() { + Ok(out) + } else { + Err(problems) + } +} + +fn pascal(name: &str) -> String { + name.split('_') + .filter(|s| !s.is_empty()) + .map(|s| { + let mut chars = s.chars(); + match chars.next() { + Some(head) => format!("{}{}", head.to_ascii_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect() +} + +/// 뷰 선언 → 기계 측 레코드 타입 생성. 타입 유도가 스키마와 어긋나면 +/// 생성 대신 문제 목록을 돌려준다 (추측 생성 금지). +pub fn generate_view_types( + file: &WireFile, + schema: &SpockSchema, +) -> Result> { + let mut problems = Vec::new(); + let mut out = String::new(); + for (i, view) in file.views.iter().enumerate() { + let Some(table) = schema.find_table(&view.source) else { + problems.push(format!( + "view {} is from unknown table `{}`", + view.name, view.source + )); + continue; + }; + let _ = i; + let mut fields = String::new(); + let mut enums: Vec = Vec::new(); + for entry in &view.entries { + let (name, ty) = match entry { + ViewEntry::Column { name, projected } => match table.find_column(name) { + None => { + problems.push(format!( + "view {}: `{}` is not a column of `{}`", + view.name, name, view.source + )); + continue; + } + Some(column) => match projected { + Some(target) => { + if !schema.has_table(&column.base) { + problems.push(format!( + "view {}: `{}` projects `->` but is not a foreign key", + view.name, name + )); + continue; + } + (name.clone(), target.clone()) + } + None => match column_machine_type(&view.source, column) { + Some(ty) => (name.clone(), ty), + None => { + problems.push(format!( + "view {}: column `{}` of type `{}` needs an explicit projection", + view.name, name, column.base + )); + continue; + } + }, + }, + }, + ViewEntry::Ago { name, column } => { + match table.find_column(column) { + Some(c) if c.base == "timestamp" => {} + Some(c) => problems.push(format!( + "view {}: ago(`{}`) needs a timestamp, found `{}`", + view.name, column, c.base + )), + None => problems.push(format!( + "view {}: ago references unknown column `{}`", + view.name, column + )), + } + (name.clone(), "Text".to_string()) + } + ViewEntry::Count { name, table: t } => { + if !schema.has_table(t) { + problems.push(format!( + "view {}: count over unknown table `{t}`", + view.name + )); + } + (name.clone(), "Nat".to_string()) + } + ViewEntry::Exists { name, table: t } => { + if !schema.has_table(t) { + problems.push(format!( + "view {}: exists over unknown table `{t}`", + view.name + )); + } + (name.clone(), "Bool".to_string()) + } + ViewEntry::Match { name, column, arms } => { + match table.find_column(column) { + None => problems.push(format!( + "view {}: match on unknown column `{}`", + view.name, column + )), + Some(c) if c.base != "enum" => problems.push(format!( + "view {}: match on `{}` needs an inline-enum column, found `{}`", + view.name, column, c.base + )), + Some(_) => {} + } + let enum_name = pascal(name); + let mut body = format!("pub enum {enum_name} {{\n"); + for arm in arms { + body.push_str(&format!(" {} {{\n", arm.variant)); + for (field_name, vf) in &arm.fields { + let ty = match vf { + VariantField::Scalar { column, class } => { + match table.find_column(column) { + Some(c) if c.base == "storage_object" => {} + Some(c) => problems.push(format!( + "view {}: `{}` as {class} needs storage_object, found `{}`", + view.name, column, c.base + )), + None => problems.push(format!( + "view {}: arm `{}` references unknown column `{}`", + view.name, arm.tag, column + )), + } + match asset_type(class) { + Some(ty) => ty.to_string(), + None => { + problems.push(format!( + "view {}: unknown asset class `{class}`", + view.name + )); + continue; + } + } + } + VariantField::Each { + table: t, + column, + class, + } => { + match schema.find_table(t) { + None => problems.push(format!( + "view {}: each over unknown table `{t}`", + view.name + )), + Some(sub) => match sub.find_column(column) { + Some(c) if c.base == "storage_object" => {} + Some(c) => problems.push(format!( + "view {}: each `{t}.{column}` as {class} needs storage_object, found `{}`", + view.name, c.base + )), + None => problems.push(format!( + "view {}: `{t}` has no column `{column}`", + view.name + )), + }, + } + match asset_type(class) { + Some(ty) => format!("Seq<{ty}>"), + None => { + problems.push(format!( + "view {}: unknown asset class `{class}`", + view.name + )); + continue; + } + } + } + }; + body.push_str(&format!(" {field_name}: {ty},\n")); + } + body.push_str(" },\n"); + } + body.push_str("}\n"); + enums.push(body); + (name.clone(), enum_name) + } + ViewEntry::Tiles { name, table: t } => { + if !schema.has_table(t) { + problems.push(format!( + "view {}: tiles of unknown table `{t}`", + view.name + )); + } + (name.clone(), "Seq".to_string()) + } + ViewEntry::RowAs { name, ty } => (name.clone(), ty.clone()), + }; + fields.push_str(&format!(" {name}: {ty},\n")); + } + for body in enums { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&body); + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&format!("pub struct {} {{\n{fields}}}\n", view.name)); + } + if problems.is_empty() { + Ok(out) + } else { + Err(problems) + } +} + +fn emit_variant(out: &mut String, name: &str, fields: &[Field]) { + if fields.is_empty() { + out.push_str(&format!(" {name},\n")); + } else { + out.push_str(&format!(" {name} {{\n")); + for f in fields { + out.push_str(&format!(" {}: {},\n", f.name, f.ty)); + } + out.push_str(" },\n"); + } +} + +/// 기계 측 계약 선언문 생성. Settlement의 Accepted/Refused는 0.4 결과 어휘에 +/// 고정된 규약이므로 언어가 강제하고, extras만 파일 선언을 따른다. +pub fn generate_machine_types(file: &WireFile) -> String { + let mut out = String::from("pub enum Mutation {\n"); + for m in &file.mutations { + emit_variant(&mut out, &m.name, &m.fields); + } + out.push_str("}\n\npub enum Settlement {\n Accepted,\n Refused {\n reason: Text,\n },\n"); + for (name, fields) in &file.settlement.extras { + emit_variant(&mut out, name, fields); + } + out.push_str("}\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_table_id_refs_to_machine_id_types() { + assert_eq!(machine_type("post.id").unwrap(), "PostId"); + assert_eq!(machine_type("user.id").unwrap(), "UserId"); + assert_eq!(machine_type("story.id").unwrap(), "StoryId"); + assert_eq!(machine_type("text").unwrap(), "Text"); + assert_eq!(machine_type("bool").unwrap(), "Bool"); + } + + #[test] + fn rejects_unknown_types_instead_of_guessing() { + assert!(machine_type("post.author").is_err()); + assert!(machine_type("json").is_err()); + } + + #[test] + fn unit_mutation_needs_no_fields() { + let file = parse("mutation LoadMore -> local { resnapshot; };").unwrap(); + assert_eq!(file.mutations.len(), 1); + assert!(file.mutations[0].fields.is_empty()); + assert_eq!(file.mutations[0].policy, "local { resnapshot; }"); + } + + #[test] + fn malformed_source_is_a_parse_error() { + assert!(parse("mutation { post: post.id }").is_err()); + } +} +// 스키마 입력: 컴파일러가 방출한 계약 JSON(`spock build` / GET /~contract)을 +// 소비한다. .spock 소스 텍스트를 직접 파싱하지 않는다 — 계약이 진실이고 +// (텍스트 파싱은 storage_object 시스템 테이블을 놓쳤다), additively frozen +// 이라 안정된 입력이다. + + + +#[derive(Debug, Default, PartialEq)] +pub struct SpockSchema { + pub tables: Vec, + pub errors: Vec, + pub fns: Vec, +} + +#[derive(Debug, Default, PartialEq)] +pub struct SpockTable { + pub name: String, + pub columns: Vec, +} + +#[derive(Debug, PartialEq)] +pub struct SpockColumn { + pub name: String, + /// 기본 타입 토큰: uuid/text/timestamp/bool/int, FK면 대상 테이블 이름 + /// (storage_object 포함), 인라인 열거(set)면 "enum". + pub base: String, + pub key: bool, +} + +#[derive(Debug, PartialEq)] +pub struct SpockFn { + pub name: String, + pub errors: Vec, + pub mutating: bool, +} + +impl SpockSchema { + pub fn has_table(&self, name: &str) -> bool { + self.tables.iter().any(|t| t.name == name) + } + pub fn find_table(&self, name: &str) -> Option<&SpockTable> { + self.tables.iter().find(|t| t.name == name) + } + pub fn find_fn(&self, name: &str) -> Option<&SpockFn> { + self.fns.iter().find(|f| f.name == name) + } +} + +impl SpockTable { + pub fn find_column(&self, name: &str) -> Option<&SpockColumn> { + self.columns.iter().find(|c| c.name == name) + } +} + +fn column_base(ty: &Value) -> Option { + match ty.get("kind")?.as_str()? { + "ref" => Some(ty.get("table")?.as_str()?.to_string()), + "set" => Some("enum".to_string()), + scalar => Some(scalar.to_string()), + } +} + +/// 계약 JSON → 스키마. 모양이 어긋나면 추측하지 않고 문제 목록을 돌려준다. +pub fn extract_contract(source: &str) -> Result> { + let root: Value = match serde_json::from_str(source) { + Ok(v) => v, + Err(e) => return Err(vec![format!("contract is not valid JSON: {e}")]), + }; + let mut problems = Vec::new(); + let mut schema = SpockSchema::default(); + + match root.get("tables").and_then(Value::as_array) { + None => problems.push("contract has no `tables` array".to_string()), + Some(tables) => { + for t in tables { + let Some(name) = t.get("name").and_then(Value::as_str) else { + problems.push("table entry without a name".to_string()); + continue; + }; + let keys: Vec<&str> = t + .get("key") + .and_then(Value::as_array) + .map(|k| k.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let mut table = SpockTable { + name: name.to_string(), + columns: Vec::new(), + }; + for f in t + .get("fields") + .and_then(Value::as_array) + .unwrap_or(&Vec::new()) + { + let Some(col_name) = f.get("name").and_then(Value::as_str) else { + problems.push(format!("table `{name}`: field without a name")); + continue; + }; + let Some(base) = f.get("type").and_then(column_base) else { + problems.push(format!( + "table `{name}`: field `{col_name}` has an unrecognized type" + )); + continue; + }; + table.columns.push(SpockColumn { + name: col_name.to_string(), + base, + key: keys.contains(&col_name), + }); + } + schema.tables.push(table); + } + } + } + + for e in root + .get("errors") + .and_then(Value::as_array) + .unwrap_or(&Vec::new()) + { + if let Some(code) = e.get("code").and_then(Value::as_str) { + schema.errors.push(code.to_string()); + } + } + + match root.get("fns").and_then(Value::as_array) { + None => problems.push("contract has no `fns` array".to_string()), + Some(fns) => { + for f in fns { + let Some(name) = f.get("name").and_then(Value::as_str) else { + problems.push("fn entry without a name".to_string()); + continue; + }; + let errors = f + .get("errors") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + let mutating = !f + .get("readonly") + .and_then(Value::as_bool) + .unwrap_or(false); + schema.fns.push(SpockFn { + name: name.to_string(), + errors, + mutating, + }); + } + } + } + + if problems.is_empty() { + Ok(schema) + } else { + Err(problems) + } +} + +// ===== CLI seam: typed contract + declaration path → provider module ===== + +/// `spock gen provider --app `: 계약(컴파일러 소유)과 앱 선언에서 +/// provider 테이블 모듈을 생성한다. 문제는 사람이 읽을 목록으로 합쳐 실패시킨다. +pub fn generate_from_contract( + contract: &C, + app_declaration: &std::path::Path, +) -> Result { + let json = serde_json::to_string(contract) + .map_err(|e| format!("contract serialization failed: {e}"))?; + let schema = extract_contract(&json).map_err(|p| p.join("\n"))?; + let source = std::fs::read_to_string(app_declaration) + .map_err(|e| format!("cannot read {}: {e}", app_declaration.display()))?; + let file = parse(&source).map_err(|e| e.to_string())?; + let problems = validate_against(&file, &schema); + if !problems.is_empty() { + return Err(problems.join("\n")); + } + generate_provider_module(&file, &schema).map_err(|p| p.join("\n")) +} diff --git a/crates/spock-cli/src/provider_gen/wire.pest b/crates/spock-cli/src/provider_gen/wire.pest new file mode 100644 index 0000000..df16675 --- /dev/null +++ b/crates/spock-cli/src/provider_gen/wire.pest @@ -0,0 +1,59 @@ +WHITESPACE = _{ " " | "\t" | "\r" | "\n" } +COMMENT = _{ "//" ~ (!"\n" ~ ANY)* } + +file = { SOI ~ item* ~ EOI } +item = _{ use_decl | app_decl | snapshot | view | mutation | settlement | fixtures } + +ident = @{ (ASCII_ALPHANUMERIC | "_")+ } +dotted = @{ ident ~ ("." ~ ident)* } +string = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" } + +// 균형 잡힌 원시 블록: v0.1이 해석하지 않는 본문(뷰/스냅샷/픽스처)을 통째로 보존 +rawblock = @{ "{" ~ (rawblock | !("{" | "}") ~ ANY)* ~ "}" } + +use_decl = { "use" ~ ident ~ string ~ ";" } +app_decl = { "app" ~ string ~ ";" } + +number = @{ ASCII_DIGIT+ } +cap_decl = { "cap" ~ number ~ "per" ~ "table" ~ ";" } +read_entry = { ident ~ ("as" ~ ident)? } +read_decl = { "read" ~ read_entry ~ ("," ~ read_entry)* ~ ";" } +snapshot = { "snapshot" ~ ident ~ "{" ~ (cap_decl | read_decl)* ~ "}" } + +// 뷰: 컬럼 항목과 파생 항목. 조건절(where …)은 v0.1이 타입만 유도하고 +// 본문은 원시 보존한다. +raw_cond = @{ (!(";" | "{" | "}") ~ ANY)* } +raw_tail = @{ (!("," | ";" | "{" | "}") ~ ANY)* } +ago_e = { "ago" ~ "(" ~ ident ~ ")" } +count_e = { "count" ~ ident ~ raw_cond } +exists_e = { "exists" ~ ident ~ raw_cond } +tiles_e = { "tiles" ~ "of" ~ ident ~ raw_cond } +rowas_e = { "row" ~ "as" ~ ident } + +// match 팔: 태그 문자열 → 변형 { 필드: 자산 투영 }. +// 자산 클래스(`as image`/`as url`)가 storage_object의 기계 타입을 결정한다. +each_e = { "each" ~ ident ~ "." ~ ident ~ "as" ~ ident ~ raw_tail } +asof_e = { ident ~ "as" ~ ident } +vfexpr = { each_e | asof_e } +vfield = { ident ~ ":" ~ vfexpr } +arm = { string ~ "=>" ~ ident ~ "{" ~ vfield ~ ("," ~ vfield)* ~ ","? ~ "}" } +match_e = { "match" ~ ident ~ "{" ~ arm ~ ("," ~ arm)* ~ ","? ~ "}" } +vexpr = { ago_e | count_e | exists_e | match_e | tiles_e | rowas_e } +computed = { ident ~ "=" ~ vexpr ~ ";" } +column = { ident ~ ("->" ~ ident)? ~ ";" } +view = { "view" ~ ident ~ "from" ~ ident ~ "{" ~ (computed | column)* ~ "}" } +// 비디오는 매니페스트에 파생원이 없어 명시 선언한다. +video_decl = { "video" ~ string ~ "=" ~ string ~ ";" } +fixtures = { "fixtures" ~ "from" ~ dotted ~ "{" ~ (video_decl | raw_stmt)* ~ "}" } + +field = { ident ~ ":" ~ dotted } +fields = { "{" ~ (field ~ ("," ~ field)* ~ ","?)? ~ "}" } + +// 정책: `->` 뒤부터 깊이 0의 `;`까지 (중괄호 안 `;`는 소비) +policy = @{ "->" ~ (rawblock | !(";" | "{" | "}") ~ ANY)* } +// `op`: 백엔드 연산 kind 오버라이드 (기본값 = 뮤테이션 이름의 snake_case) +mutation = { "mutation" ~ ident ~ ("op" ~ ident)? ~ fields? ~ policy? ~ ";" } + +extra_decl = { "extra" ~ ident ~ fields ~ ";" } +raw_stmt = @{ (rawblock | !(";" | "{" | "}") ~ ANY)+ ~ ";" } +settlement = { "settlement" ~ "{" ~ (extra_decl | raw_stmt)* ~ "}" } diff --git a/crates/spock-cli/tests/provider_fixtures/contract.json b/crates/spock-cli/tests/provider_fixtures/contract.json new file mode 100644 index 0000000..87c8e8f --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/contract.json @@ -0,0 +1,3842 @@ +{ + "spock": "v0", + "doc": "instagram — the Spock authority in the canonical full-stack framework example.\n\nRFD 0024 IMPLEMENTATION PREVIEW — EXPERIMENTAL, UNSTABLE, AND\nNON-NORMATIVE. The `0.5.2` toolchain accepts the top-level `error`\ndeclarations in this file as implementation evidence, not accepted v0 or a\ncompatibility promise.\n\nThis program is the live authority behind the sibling Uhura client. Its\ntables and seed mirror the demo's\nstandard fixture (fixtures/standard.toml), with the fixture's generated\nimages materialized as Spock storage objects. Its fns satisfy the feed,\ncomments, profile, people, story, saved-library, and create port contracts.\nThe client-owned Play provider assembles projection shapes from Spock GraphQL,\nsigns storage downloads, and maps port commands onto `POST /rest/v1/rpc/*`.\n\nIdentity is the v0 dev seam (RFD 0014): the driver acts as Mira by sending\n`X-Spock-Actor: 10000000-0000-4000-8000-000000000001` — user keys are\nliteral uuids in the seed, so ids are stable across restarts.\n\nImage and video file columns are `storage_object` references. Existing demo\nJPEGs and MP4s enter through `file(\"./seed/…\")`; uploaded bytes enter through\nSpock's signed storage protocol and can be attached with `create_image_post`.\n\nCounts are intentionally absent from the stored model. Posts, likes,\ncomments, follows, saves, and story views are source rows; the Play provider\nderives every number and label shown by Uhura from a GraphQL snapshot. Saves\nremain private viewer state and are never presented as public engagement.\nThe 162-row demo seed exercises 9 actors, 23 posts, 12 story frames, and the\nthree stored-media shapes: image, carousel, and playable video plus poster.", + "errors": [ + { + "code": "not_authorized", + "doc": "The current actor is absent or is not permitted to perform the operation." + }, + { + "code": "cannot_follow_self", + "doc": "A person cannot follow themselves." + }, + { + "code": "image_not_ready", + "doc": "The uploaded image is not ready to be attached to a post." + }, + { + "code": "unsupported_media_type", + "doc": "The uploaded object is not a supported image type." + } + ], + "tables": [ + { + "name": "user", + "doc": "A demo person. The identity anchor: `user.id` is what `X-Spock-Actor`\ncarries and `spock_actor()` returns. Profile counts are derived from post\nand follow rows instead of being stored here as decorative labels.", + "key": [ + "id" + ], + "fields": [ + { + "name": "id", + "type": { + "kind": "uuid" + }, + "optional": false, + "unique": false, + "default": { + "kind": "auto" + } + }, + { + "name": "username", + "doc": "The unique handle, e.g. \"mira.santos\".", + "type": { + "kind": "text" + }, + "optional": false, + "unique": true, + "default": null + }, + { + "name": "display_name", + "doc": "Full display name, e.g. \"Mira Santos\".", + "type": { + "kind": "text" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "avatar", + "doc": "The avatar image stored in Spock's byte plane.", + "type": { + "kind": "ref", + "table": "storage_object", + "on_delete": "restrict" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "avatar_alt", + "doc": "Alt text for the avatar.", + "type": { + "kind": "text" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "bio", + "doc": "Profile bio.", + "type": { + "kind": "text" + }, + "optional": false, + "unique": false, + "default": null + } + ], + "uniques": [], + "anchor": true, + "errors": [ + { + "code": "user_already_exists", + "kind": "key", + "fields": [ + "id" + ], + "status": 409 + }, + { + "code": "user_username_taken", + "kind": "unique", + "fields": [ + "username" + ], + "status": 409 + }, + { + "code": "user_username_required", + "kind": "required", + "fields": [ + "username" + ], + "status": 422 + }, + { + "code": "user_display_name_required", + "kind": "required", + "fields": [ + "display_name" + ], + "status": 422 + }, + { + "code": "user_avatar_required", + "kind": "required", + "fields": [ + "avatar" + ], + "status": 422 + }, + { + "code": "user_avatar_alt_required", + "kind": "required", + "fields": [ + "avatar_alt" + ], + "status": 422 + }, + { + "code": "user_bio_required", + "kind": "required", + "fields": [ + "bio" + ], + "status": 422 + }, + { + "code": "user_avatar_not_found", + "kind": "ref_not_found", + "fields": [ + "avatar" + ], + "status": 422 + }, + { + "code": "user_restricted", + "kind": "restricted", + "fields": [], + "status": 409 + } + ] + }, + { + "name": "follow", + "doc": "A directed follower edge. Profile follower/following counts and people\nlists are computed from these rows.", + "key": [ + "follower", + "followed" + ], + "fields": [ + { + "name": "follower", + "doc": "The person doing the following.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "followed", + "doc": "The person being followed.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "at", + "doc": "When the relationship began.", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + } + ], + "uniques": [], + "errors": [ + { + "code": "follow_already_exists", + "kind": "key", + "fields": [ + "follower", + "followed" + ], + "status": 409 + }, + { + "code": "follow_follower_required", + "kind": "required", + "fields": [ + "follower" + ], + "status": 422 + }, + { + "code": "follow_followed_required", + "kind": "required", + "fields": [ + "followed" + ], + "status": 422 + }, + { + "code": "follow_at_required", + "kind": "required", + "fields": [ + "at" + ], + "status": 422 + }, + { + "code": "follow_follower_not_found", + "kind": "ref_not_found", + "fields": [ + "follower" + ], + "status": 422 + }, + { + "code": "follow_followed_not_found", + "kind": "ref_not_found", + "fields": [ + "followed" + ], + "status": 422 + } + ] + }, + { + "name": "story", + "doc": "One active story frame. Authors can own a sequence of frames; position is\nunique within that active sequence so playback order is deterministic.", + "key": [ + "id" + ], + "fields": [ + { + "name": "id", + "type": { + "kind": "uuid" + }, + "optional": false, + "unique": false, + "default": { + "kind": "auto" + } + }, + { + "name": "author", + "doc": "Who published the frame.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "position", + "doc": "Position within the author's current story, 1-based.", + "type": { + "kind": "int" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "media_file", + "doc": "Story image stored in Spock's byte plane.", + "type": { + "kind": "ref", + "table": "storage_object", + "on_delete": "restrict" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "media_alt", + "doc": "Accessible alternative text.", + "type": { + "kind": "text" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "caption", + "doc": "Optional overlay caption.", + "type": { + "kind": "text" + }, + "optional": true, + "unique": false, + "default": null + }, + { + "name": "published_at", + "doc": "Real publication instant; the provider formats relative time.", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + } + ], + "uniques": [ + [ + "author", + "position" + ] + ], + "errors": [ + { + "code": "story_already_exists", + "kind": "key", + "fields": [ + "id" + ], + "status": 409 + }, + { + "code": "story_author_position_taken", + "kind": "unique", + "fields": [ + "author", + "position" + ], + "status": 409 + }, + { + "code": "story_author_required", + "kind": "required", + "fields": [ + "author" + ], + "status": 422 + }, + { + "code": "story_position_required", + "kind": "required", + "fields": [ + "position" + ], + "status": 422 + }, + { + "code": "story_media_file_required", + "kind": "required", + "fields": [ + "media_file" + ], + "status": 422 + }, + { + "code": "story_media_alt_required", + "kind": "required", + "fields": [ + "media_alt" + ], + "status": 422 + }, + { + "code": "story_published_at_required", + "kind": "required", + "fields": [ + "published_at" + ], + "status": 422 + }, + { + "code": "story_author_not_found", + "kind": "ref_not_found", + "fields": [ + "author" + ], + "status": 422 + }, + { + "code": "story_media_file_not_found", + "kind": "ref_not_found", + "fields": [ + "media_file" + ], + "status": 422 + } + ] + }, + { + "name": "story_view", + "doc": "A viewer has seen a specific story frame. Ring state is derived: an\nauthor's ring is unseen when at least one current frame lacks this edge.", + "key": [ + "viewer", + "story" + ], + "fields": [ + { + "name": "viewer", + "doc": "Who viewed the frame.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "story", + "doc": "Which frame was viewed.", + "type": { + "kind": "ref", + "table": "story", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "at", + "doc": "When it was viewed.", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + } + ], + "uniques": [], + "errors": [ + { + "code": "story_view_already_exists", + "kind": "key", + "fields": [ + "viewer", + "story" + ], + "status": 409 + }, + { + "code": "story_view_viewer_required", + "kind": "required", + "fields": [ + "viewer" + ], + "status": 422 + }, + { + "code": "story_view_story_required", + "kind": "required", + "fields": [ + "story" + ], + "status": 422 + }, + { + "code": "story_view_at_required", + "kind": "required", + "fields": [ + "at" + ], + "status": 422 + }, + { + "code": "story_view_viewer_not_found", + "kind": "ref_not_found", + "fields": [ + "viewer" + ], + "status": 422 + }, + { + "code": "story_view_story_not_found", + "kind": "ref_not_found", + "fields": [ + "story" + ], + "status": 422 + } + ] + }, + { + "name": "post", + "doc": "A published post. `media_kind` selects the port's media union variant: an\n`image` row carries `media_file`/`media_alt`; a `video` row additionally\ncarries playable `video_file` bytes while retaining `media_file` as its\nposter; a `carousel` row leaves those fields absent and owns\n`carousel_slide` rows instead.", + "key": [ + "id" + ], + "fields": [ + { + "name": "id", + "type": { + "kind": "uuid" + }, + "optional": false, + "unique": false, + "default": { + "kind": "auto" + } + }, + { + "name": "author", + "doc": "The author.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "restrict" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "caption", + "doc": "Caption text. An empty string means the author published without one.", + "type": { + "kind": "text" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "published_at", + "doc": "Real publication instant; the provider derives age labels and ordering.", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + }, + { + "name": "show_in_feed", + "doc": "Whether this post participates in the home feed. Older profile posts\nremain first-class posts but do not flood the short demo feed.", + "type": { + "kind": "bool" + }, + "optional": false, + "unique": false, + "default": { + "kind": "bool", + "value": true + } + }, + { + "name": "media_kind", + "doc": "Which media union variant this post renders as.", + "type": { + "kind": "set", + "values": [ + "image", + "carousel", + "video" + ] + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "media_file", + "doc": "Image or video-poster object; absent for carousels.", + "type": { + "kind": "ref", + "table": "storage_object", + "on_delete": "restrict" + }, + "optional": true, + "unique": false, + "default": null + }, + { + "name": "video_file", + "doc": "Playable video bytes; present only for video posts.", + "type": { + "kind": "ref", + "table": "storage_object", + "on_delete": "restrict" + }, + "optional": true, + "unique": false, + "default": null + }, + { + "name": "media_alt", + "doc": "Alt text for `media_file`; absent for carousels.", + "type": { + "kind": "text" + }, + "optional": true, + "unique": false, + "default": null + } + ], + "uniques": [], + "errors": [ + { + "code": "post_already_exists", + "kind": "key", + "fields": [ + "id" + ], + "status": 409 + }, + { + "code": "post_author_required", + "kind": "required", + "fields": [ + "author" + ], + "status": 422 + }, + { + "code": "post_caption_required", + "kind": "required", + "fields": [ + "caption" + ], + "status": 422 + }, + { + "code": "post_published_at_required", + "kind": "required", + "fields": [ + "published_at" + ], + "status": 422 + }, + { + "code": "post_show_in_feed_required", + "kind": "required", + "fields": [ + "show_in_feed" + ], + "status": 422 + }, + { + "code": "post_media_kind_required", + "kind": "required", + "fields": [ + "media_kind" + ], + "status": 422 + }, + { + "code": "post_author_not_found", + "kind": "ref_not_found", + "fields": [ + "author" + ], + "status": 422 + }, + { + "code": "post_media_file_not_found", + "kind": "ref_not_found", + "fields": [ + "media_file" + ], + "status": 422 + }, + { + "code": "post_video_file_not_found", + "kind": "ref_not_found", + "fields": [ + "video_file" + ], + "status": 422 + }, + { + "code": "post_media_kind_invalid", + "kind": "invalid", + "fields": [ + "media_kind" + ], + "status": 422 + } + ] + }, + { + "name": "post_tag", + "doc": "A person tagged in a published post. The composite key prevents duplicate\ntags while keeping the post itself as the single media/caption authority\nused by feed, profile-grid, tagged-grid, and post-detail projections. A tag\ndisappears with either endpoint: deleting a post cannot leave a dangling\nprofile tile, and deleting a person removes references to that identity.", + "key": [ + "post", + "person" + ], + "fields": [ + { + "name": "post", + "doc": "The post carrying the tag.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "person", + "doc": "The person tagged in that post.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + } + ], + "uniques": [], + "errors": [ + { + "code": "post_tag_already_exists", + "kind": "key", + "fields": [ + "post", + "person" + ], + "status": 409 + }, + { + "code": "post_tag_post_required", + "kind": "required", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "post_tag_person_required", + "kind": "required", + "fields": [ + "person" + ], + "status": 422 + }, + { + "code": "post_tag_post_not_found", + "kind": "ref_not_found", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "post_tag_person_not_found", + "kind": "ref_not_found", + "fields": [ + "person" + ], + "status": 422 + } + ] + }, + { + "name": "carousel_slide", + "doc": "One slide of a carousel post, in slide order.", + "key": [ + "id" + ], + "fields": [ + { + "name": "id", + "type": { + "kind": "uuid" + }, + "optional": false, + "unique": false, + "default": { + "kind": "auto" + } + }, + { + "name": "post", + "doc": "The carousel post this slide belongs to.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "position", + "doc": "Slide order, 1-based.", + "type": { + "kind": "int" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "file", + "doc": "The slide image stored in Spock's byte plane.", + "type": { + "kind": "ref", + "table": "storage_object", + "on_delete": "restrict" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "alt", + "doc": "Alt text for the slide.", + "type": { + "kind": "text" + }, + "optional": false, + "unique": false, + "default": null + } + ], + "uniques": [], + "errors": [ + { + "code": "carousel_slide_already_exists", + "kind": "key", + "fields": [ + "id" + ], + "status": 409 + }, + { + "code": "carousel_slide_post_required", + "kind": "required", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "carousel_slide_position_required", + "kind": "required", + "fields": [ + "position" + ], + "status": 422 + }, + { + "code": "carousel_slide_file_required", + "kind": "required", + "fields": [ + "file" + ], + "status": 422 + }, + { + "code": "carousel_slide_alt_required", + "kind": "required", + "fields": [ + "alt" + ], + "status": 422 + }, + { + "code": "carousel_slide_post_not_found", + "kind": "ref_not_found", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "carousel_slide_file_not_found", + "kind": "ref_not_found", + "fields": [ + "file" + ], + "status": 422 + } + ] + }, + { + "name": "comment", + "doc": "A comment on a post. Thread order and age labels derive from `created_at`.", + "key": [ + "id" + ], + "fields": [ + { + "name": "id", + "type": { + "kind": "uuid" + }, + "optional": false, + "unique": false, + "default": { + "kind": "auto" + } + }, + { + "name": "post", + "doc": "The post commented on.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "author", + "doc": "Who wrote it.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "restrict" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "body", + "doc": "The comment text (non-empty).", + "type": { + "kind": "text" + }, + "optional": false, + "unique": false, + "default": null, + "check": "nonempty" + }, + { + "name": "created_at", + "doc": "Real creation instant.", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + } + ], + "uniques": [], + "errors": [ + { + "code": "comment_already_exists", + "kind": "key", + "fields": [ + "id" + ], + "status": 409 + }, + { + "code": "comment_post_required", + "kind": "required", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "comment_author_required", + "kind": "required", + "fields": [ + "author" + ], + "status": 422 + }, + { + "code": "comment_body_required", + "kind": "required", + "fields": [ + "body" + ], + "status": 422 + }, + { + "code": "comment_created_at_required", + "kind": "required", + "fields": [ + "created_at" + ], + "status": 422 + }, + { + "code": "comment_post_not_found", + "kind": "ref_not_found", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "comment_author_not_found", + "kind": "ref_not_found", + "fields": [ + "author" + ], + "status": 422 + }, + { + "code": "comment_body_invalid", + "kind": "invalid", + "fields": [ + "body" + ], + "status": 422 + } + ] + }, + { + "name": "like", + "doc": "A like edge. The composite key makes a like at-most-once per (user, post)\nby construction — no separate `unique` needed.", + "key": [ + "user", + "post" + ], + "fields": [ + { + "name": "user", + "doc": "Who liked.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "restrict" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "post", + "doc": "The liked post.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "at", + "doc": "When the like landed.", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + } + ], + "uniques": [], + "errors": [ + { + "code": "like_already_exists", + "kind": "key", + "fields": [ + "user", + "post" + ], + "status": 409 + }, + { + "code": "like_user_required", + "kind": "required", + "fields": [ + "user" + ], + "status": 422 + }, + { + "code": "like_post_required", + "kind": "required", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "like_at_required", + "kind": "required", + "fields": [ + "at" + ], + "status": 422 + }, + { + "code": "like_user_not_found", + "kind": "ref_not_found", + "fields": [ + "user" + ], + "status": 422 + }, + { + "code": "like_post_not_found", + "kind": "ref_not_found", + "fields": [ + "post" + ], + "status": 422 + } + ] + }, + { + "name": "save", + "doc": "A post saved to one user's private library. The composite key makes saving\nidempotent. Unlike a like, this edge is viewer-private state: it produces no\nnotification and has no public count.", + "key": [ + "user", + "post" + ], + "fields": [ + { + "name": "user", + "doc": "The user who saved the post. RPCs always stamp the current actor.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "post", + "doc": "The saved post.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "cascade" + }, + "optional": false, + "unique": false, + "default": null + }, + { + "name": "at", + "doc": "When the post was saved.", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + } + ], + "uniques": [], + "errors": [ + { + "code": "save_already_exists", + "kind": "key", + "fields": [ + "user", + "post" + ], + "status": 409 + }, + { + "code": "save_user_required", + "kind": "required", + "fields": [ + "user" + ], + "status": 422 + }, + { + "code": "save_post_required", + "kind": "required", + "fields": [ + "post" + ], + "status": 422 + }, + { + "code": "save_at_required", + "kind": "required", + "fields": [ + "at" + ], + "status": 422 + }, + { + "code": "save_user_not_found", + "kind": "ref_not_found", + "fields": [ + "user" + ], + "status": 422 + }, + { + "code": "save_post_not_found", + "kind": "ref_not_found", + "fields": [ + "post" + ], + "status": 422 + } + ] + }, + { + "name": "storage_object", + "doc": "A stored file. Metadata for a byte object whose bytes live in the runtime's blob store: created pending on upload, committed when its bytes land, swept when abandoned or unreferenced (RFD 0018).", + "key": [ + "id" + ], + "fields": [ + { + "name": "id", + "type": { + "kind": "uuid" + }, + "optional": false, + "unique": false, + "default": { + "kind": "auto" + } + }, + { + "name": "owner", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "set_null" + }, + "optional": true, + "unique": false, + "default": { + "kind": "actor" + } + }, + { + "name": "name", + "type": { + "kind": "text" + }, + "optional": true, + "unique": false, + "default": null + }, + { + "name": "content_type", + "type": { + "kind": "text" + }, + "optional": true, + "unique": false, + "default": null + }, + { + "name": "size", + "type": { + "kind": "int" + }, + "optional": true, + "unique": false, + "default": null + }, + { + "name": "checksum", + "type": { + "kind": "text" + }, + "optional": true, + "unique": false, + "default": null + }, + { + "name": "state", + "type": { + "kind": "set", + "values": [ + "pending", + "committed" + ] + }, + "optional": false, + "unique": false, + "default": { + "kind": "str", + "value": "pending" + } + }, + { + "name": "created_at", + "type": { + "kind": "timestamp" + }, + "optional": false, + "unique": false, + "default": { + "kind": "now" + } + } + ], + "uniques": [], + "builtin": true, + "errors": [ + { + "code": "storage_object_already_exists", + "kind": "key", + "fields": [ + "id" + ], + "status": 409 + }, + { + "code": "storage_object_state_required", + "kind": "required", + "fields": [ + "state" + ], + "status": 422 + }, + { + "code": "storage_object_created_at_required", + "kind": "required", + "fields": [ + "created_at" + ], + "status": 422 + }, + { + "code": "storage_object_owner_not_found", + "kind": "ref_not_found", + "fields": [ + "owner" + ], + "status": 422 + }, + { + "code": "storage_object_state_invalid", + "kind": "invalid", + "fields": [ + "state" + ], + "status": 422 + }, + { + "code": "storage_object_restricted", + "kind": "restricted", + "fields": [], + "status": 409 + } + ] + } + ], + "records": [], + "fns": [ + { + "name": "nonempty", + "doc": "A comment body must contain something other than spaces, tabs, or line\nbreaks (RFD 0013 validator; derives `comment_body_invalid` on the whole\nfloor, fn escapes included).", + "readonly": true, + "params": [ + { + "name": "s", + "type": { + "kind": "text" + }, + "optional": false + } + ], + "returns": { + "arity": "one", + "of": "bool", + "scalar": true + }, + "errors": [], + "refusals": [], + "sql": [ + "SELECT length(trim(:s, ' ' || char(9, 10, 13))) > 0" + ] + }, + { + "name": "like_post", + "doc": "Like a post as the current actor (`X-Spock-Actor`). Idempotent: liking a\npost you already like returns the existing like row and does not touch\nany counter because the displayed count is derived from like rows. Refuses\n`not_authorized` when anonymous and answers `not_found` when the post does\nnot exist (the returning SELECT finds no row, so the transaction rolls back).", + "readonly": false, + "params": [ + { + "name": "post", + "doc": "The post to like.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "restrict" + }, + "optional": false + } + ], + "returns": { + "arity": "one", + "of": "like", + "scalar": false + }, + "errors": [ + "not_authorized", + "not_found" + ], + "refusals": [ + "not_authorized" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n INSERT INTO \"like\" (\"user\", post)\n SELECT spock_actor(), p.id FROM post p WHERE p.id = :post\n ON CONFLICT (\"user\", post) DO NOTHING\n ", + "\n SELECT * FROM \"like\" WHERE post = :post AND \"user\" = spock_actor()\n " + ] + }, + { + "name": "unlike_post", + "doc": "Remove the actor's like. Idempotent: unliking a post you don't like (or\na post that doesn't exist) returns null and changes nothing.", + "readonly": false, + "params": [ + { + "name": "post", + "doc": "The post to unlike.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "restrict" + }, + "optional": false + } + ], + "returns": { + "arity": "maybe", + "of": "like", + "scalar": false + }, + "errors": [ + "not_authorized" + ], + "refusals": [ + "not_authorized" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n DELETE FROM \"like\" WHERE post = :post AND \"user\" = spock_actor()\n RETURNING *\n " + ] + }, + { + "name": "save_post", + "doc": "Save a post to the current actor's private library. Idempotent: saving an\nalready-saved post returns the existing edge without refreshing its time.", + "readonly": false, + "params": [ + { + "name": "post", + "doc": "The post to save.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "restrict" + }, + "optional": false + } + ], + "returns": { + "arity": "one", + "of": "save", + "scalar": false + }, + "errors": [ + "not_authorized", + "not_found" + ], + "refusals": [ + "not_authorized" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n INSERT INTO save (\"user\", post)\n SELECT spock_actor(), p.id FROM post p WHERE p.id = :post\n ON CONFLICT (\"user\", post) DO NOTHING\n ", + "\n SELECT * FROM save WHERE post = :post AND \"user\" = spock_actor()\n " + ] + }, + { + "name": "unsave_post", + "doc": "Remove a post from the current actor's private library. Idempotent: an\nabsent save edge returns null and changes nothing.", + "readonly": false, + "params": [ + { + "name": "post", + "doc": "The post to remove from saved posts.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "restrict" + }, + "optional": false + } + ], + "returns": { + "arity": "maybe", + "of": "save", + "scalar": false + }, + "errors": [ + "not_authorized" + ], + "refusals": [ + "not_authorized" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n DELETE FROM save WHERE post = :post AND \"user\" = spock_actor()\n RETURNING *\n " + ] + }, + { + "name": "add_comment", + "doc": "Comment on a post as the current actor, returning the authority's echo of\nthe new row — including its minted UUIDv7 `id` and real `created_at`.\nAn empty or whitespace-only body trips the `comment_body_invalid` check; a\nmissing post is `not_found` (the answering INSERT..SELECT inserts nothing).", + "readonly": false, + "params": [ + { + "name": "post", + "doc": "The post to comment on.", + "type": { + "kind": "ref", + "table": "post", + "on_delete": "restrict" + }, + "optional": false + }, + { + "name": "body", + "doc": "The comment text (non-empty).", + "type": { + "kind": "text" + }, + "optional": false + } + ], + "returns": { + "arity": "one", + "of": "comment", + "scalar": false + }, + "errors": [ + "not_authorized", + "comment_body_invalid", + "not_found" + ], + "refusals": [ + "not_authorized" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n INSERT INTO comment (post, author, body)\n SELECT p.id, spock_actor(), :body\n FROM post p WHERE p.id = :post\n RETURNING *\n " + ] + }, + { + "name": "follow_user", + "doc": "Follow another person. Idempotent; a repeated follow returns the existing\nedge. Counts and lists update automatically because they derive from rows.", + "readonly": false, + "params": [ + { + "name": "target", + "doc": "The person to follow.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "restrict" + }, + "optional": false + } + ], + "returns": { + "arity": "one", + "of": "follow", + "scalar": false + }, + "errors": [ + "not_authorized", + "cannot_follow_self", + "not_found" + ], + "refusals": [ + "not_authorized", + "cannot_follow_self" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n SELECT spock_refuse('cannot_follow_self') WHERE spock_actor() = :target\n ", + "\n INSERT INTO follow (follower, followed)\n SELECT spock_actor(), u.id FROM \"user\" u WHERE u.id = :target\n ON CONFLICT (follower, followed) DO NOTHING\n ", + "\n SELECT * FROM follow\n WHERE follower = spock_actor() AND followed = :target\n " + ] + }, + { + "name": "unfollow_user", + "doc": "Stop following another person. Idempotent; an absent edge returns null.", + "readonly": false, + "params": [ + { + "name": "target", + "doc": "The person to stop following.", + "type": { + "kind": "ref", + "table": "user", + "on_delete": "restrict" + }, + "optional": false + } + ], + "returns": { + "arity": "maybe", + "of": "follow", + "scalar": false + }, + "errors": [ + "not_authorized" + ], + "refusals": [ + "not_authorized" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n DELETE FROM follow\n WHERE follower = spock_actor() AND followed = :target\n RETURNING *\n " + ] + }, + { + "name": "mark_story_viewed", + "doc": "Mark one story frame as viewed. Idempotent; the resulting ring state is\ncomputed by comparing all story rows with the viewer's view edges.", + "readonly": false, + "params": [ + { + "name": "story", + "doc": "The story frame that became visible.", + "type": { + "kind": "ref", + "table": "story", + "on_delete": "restrict" + }, + "optional": false + } + ], + "returns": { + "arity": "one", + "of": "story_view", + "scalar": false + }, + "errors": [ + "not_authorized", + "not_found" + ], + "refusals": [ + "not_authorized" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n INSERT INTO story_view (viewer, story)\n SELECT spock_actor(), s.id FROM story s WHERE s.id = :story\n ON CONFLICT (viewer, story) DO NOTHING\n ", + "\n SELECT * FROM story_view\n WHERE viewer = spock_actor() AND story = :story\n " + ] + }, + { + "name": "create_image_post", + "doc": "Publish one uploaded image as a new feed post. The caller first mints a\nsigned upload URL, PUTs the bytes, then passes the returned storage-object\nid here. Only a committed JPEG, PNG, or WebP object owned by the current\nactor may be attached; seed objects deliberately have no owner and cannot\nbe claimed. Storage v0 records the upload's Content-Type header; byte-level\nimage decoding/sniffing is deliberately beyond this local-dev PoC.\nRetrying the same object is idempotent: the first published post is returned.", + "readonly": false, + "params": [ + { + "name": "image", + "doc": "A committed image object minted by the current actor.", + "type": { + "kind": "ref", + "table": "storage_object", + "on_delete": "restrict" + }, + "optional": false + }, + { + "name": "caption", + "doc": "The new post's caption; empty publishes without a caption.", + "type": { + "kind": "text" + }, + "optional": false + }, + { + "name": "alt", + "doc": "Accessible alternative text for the image; empty is accepted when the\nauthor does not provide one.", + "type": { + "kind": "text" + }, + "optional": false + } + ], + "returns": { + "arity": "one", + "of": "post", + "scalar": false + }, + "errors": [ + "not_authorized", + "image_not_ready", + "unsupported_media_type" + ], + "refusals": [ + "not_authorized", + "image_not_ready", + "unsupported_media_type" + ], + "sql": [ + "\n SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL\n ", + "\n SELECT spock_refuse('image_not_ready')\n WHERE NOT EXISTS (\n SELECT 1 FROM storage_object\n WHERE id = :image AND state = 'committed'\n )\n ", + "\n SELECT spock_refuse('not_authorized')\n WHERE NOT EXISTS (\n SELECT 1 FROM storage_object\n WHERE id = :image AND owner = spock_actor()\n )\n ", + "\n SELECT spock_refuse('unsupported_media_type')\n WHERE NOT EXISTS (\n SELECT 1 FROM storage_object\n WHERE id = :image\n AND content_type IN ('image/jpeg', 'image/png', 'image/webp')\n )\n ", + "\n INSERT INTO post (\n author, caption, media_kind, media_file, media_alt\n )\n SELECT spock_actor(), :caption, 'image', :image, :alt\n WHERE NOT EXISTS (\n SELECT 1 FROM post AS existing WHERE existing.media_file = :image\n )\n ", + "\n SELECT * FROM post\n WHERE author = spock_actor() AND media_file = :image\n ORDER BY published_at\n LIMIT 1\n " + ] + } + ], + "seed": [ + { + "table": "user", + "binding": "mira", + "fields": { + "avatar": { + "file": "./seed/avatar-mira.jpg" + }, + "avatar_alt": "Mira Santos", + "bio": "Food and travel photographer in Lisbon. Usually awake before the trams.", + "display_name": "Mira Santos", + "id": "10000000-0000-4000-8000-000000000001", + "username": "mira.santos" + } + }, + { + "table": "user", + "binding": "lena", + "fields": { + "avatar": { + "file": "./seed/avatar-lena.jpg" + }, + "avatar_alt": "Lena Holt", + "bio": "Ceramics and slow mornings. Small-batch studio work from Portland.", + "display_name": "Lena Holt", + "id": "10000000-0000-4000-8000-000000000002", + "username": "lena.holt" + } + }, + { + "table": "user", + "binding": "marco", + "fields": { + "avatar": { + "file": "./seed/avatar-marco.jpg" + }, + "avatar_alt": "Marco Reyes", + "bio": "Surf photographer, road-trip cook, and reluctant morning person.", + "display_name": "Marco Reyes", + "id": "10000000-0000-4000-8000-000000000003", + "username": "marco.reyes" + } + }, + { + "table": "user", + "binding": "nils", + "fields": { + "avatar": { + "file": "./seed/avatar-nils.jpg" + }, + "avatar_alt": "Nils Bergman", + "bio": "Night skies and northern water, filmed around Tromsø.", + "display_name": "Nils Bergman", + "id": "10000000-0000-4000-8000-000000000004", + "username": "nils.bergman" + } + }, + { + "table": "user", + "binding": "priya", + "fields": { + "avatar": { + "file": "./seed/avatar-priya.jpg" + }, + "avatar_alt": "Priya Raman", + "bio": "Bread notebook, tiny kitchen, stubborn sourdough starter.", + "display_name": "Priya Raman", + "id": "10000000-0000-4000-8000-000000000005", + "username": "priya.raman" + } + }, + { + "table": "user", + "binding": "ayla", + "fields": { + "avatar": { + "file": "./seed/avatar-ayla.jpg" + }, + "avatar_alt": "Ayla Demir", + "bio": "Istanbul by ferry. Architecture, tea, and ordinary light.", + "display_name": "Ayla Demir", + "id": "10000000-0000-4000-8000-000000000006", + "username": "ayla.demir" + } + }, + { + "table": "user", + "binding": "june", + "fields": { + "avatar": { + "file": "./seed/avatar-june.jpg" + }, + "avatar_alt": "June Park", + "bio": "Natural-fiber clothes made in a very crowded studio.", + "display_name": "June Park", + "id": "10000000-0000-4000-8000-000000000007", + "username": "june.park" + } + }, + { + "table": "user", + "binding": "theo", + "fields": { + "avatar": { + "file": "./seed/avatar-theo.jpg" + }, + "avatar_alt": "Theo Okafor", + "bio": "Murals, community courts, and an unreliable jump shot.", + "display_name": "Theo Okafor", + "id": "10000000-0000-4000-8000-000000000008", + "username": "theo.okafor" + } + }, + { + "table": "user", + "binding": "kenji", + "fields": { + "avatar": { + "file": "./seed/avatar-kenji.jpg" + }, + "avatar_alt": "Kenji Tanaka", + "bio": "Long climbs, quiet roads, and coffee at the turnaround.", + "display_name": "Kenji Tanaka", + "id": "10000000-0000-4000-8000-000000000009", + "username": "kenji.rides" + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "mira" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "marco" + }, + "follower": { + "ref": "mira" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "priya" + }, + "follower": { + "ref": "mira" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "ayla" + }, + "follower": { + "ref": "mira" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "june" + }, + "follower": { + "ref": "mira" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "kenji" + }, + "follower": { + "ref": "mira" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "mira" + }, + "follower": { + "ref": "lena" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "marco" + }, + "follower": { + "ref": "lena" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "priya" + }, + "follower": { + "ref": "lena" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "june" + }, + "follower": { + "ref": "lena" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "theo" + }, + "follower": { + "ref": "lena" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "marco" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "nils" + }, + "follower": { + "ref": "marco" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "ayla" + }, + "follower": { + "ref": "marco" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "kenji" + }, + "follower": { + "ref": "marco" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "nils" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "marco" + }, + "follower": { + "ref": "nils" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "theo" + }, + "follower": { + "ref": "nils" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "mira" + }, + "follower": { + "ref": "priya" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "priya" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "ayla" + }, + "follower": { + "ref": "priya" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "june" + }, + "follower": { + "ref": "priya" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "ayla" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "priya" + }, + "follower": { + "ref": "ayla" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "june" + }, + "follower": { + "ref": "ayla" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "mira" + }, + "follower": { + "ref": "june" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "june" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "ayla" + }, + "follower": { + "ref": "june" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "theo" + }, + "follower": { + "ref": "june" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "mira" + }, + "follower": { + "ref": "theo" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "theo" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "june" + }, + "follower": { + "ref": "theo" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "lena" + }, + "follower": { + "ref": "kenji" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "marco" + }, + "follower": { + "ref": "kenji" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "nils" + }, + "follower": { + "ref": "kenji" + } + } + }, + { + "table": "follow", + "fields": { + "followed": { + "ref": "priya" + }, + "follower": { + "ref": "kenji" + } + } + }, + { + "table": "story", + "binding": "mira_story", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "Breakfast before the first tram", + "id": "30000000-0000-4000-8000-000000000001", + "media_alt": "Pastéis de nata cooling on a marble counter", + "media_file": { + "file": "./seed/thumb-mira-1.jpg" + }, + "position": 1, + "published_at": "2026-07-13T15:10:00Z" + } + }, + { + "table": "story", + "binding": "mira_story_tram", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "Then the city wakes", + "id": "30000000-0000-4000-8000-000000000008", + "media_alt": "Tram rails catching the first light in Lisbon", + "media_file": { + "file": "./seed/thumb-mira-2.jpg" + }, + "position": 2, + "published_at": "2026-07-13T15:22:00Z" + } + }, + { + "table": "story", + "binding": "mira_story_market", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "Saturday palette", + "id": "30000000-0000-4000-8000-000000000009", + "media_alt": "Crates of citrus stacked at the morning market", + "media_file": { + "file": "./seed/thumb-mira-3.jpg" + }, + "position": 3, + "published_at": "2026-07-13T15:38:00Z" + } + }, + { + "table": "story", + "binding": "lena_story", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "One pull, no edits", + "id": "30000000-0000-4000-8000-000000000002", + "media_alt": "Lena throwing a tall clay cylinder", + "media_file": { + "file": "./seed/thumb-lena-7.jpg" + }, + "position": 1, + "published_at": "2026-07-13T14:55:00Z" + } + }, + { + "table": "story", + "binding": "lena_story_glazes", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "The unglamorous half of studio day", + "id": "30000000-0000-4000-8000-000000000010", + "media_alt": "Rows of glaze buckets labelled by firing cone", + "media_file": { + "file": "./seed/thumb-lena-8.jpg" + }, + "position": 2, + "published_at": "2026-07-13T15:08:00Z" + } + }, + { + "table": "story", + "binding": "lena_story_studio", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Reset for tomorrow", + "id": "30000000-0000-4000-8000-000000000011", + "media_alt": "Morning light crossing a clean ceramics workbench", + "media_file": { + "file": "./seed/thumb-lena-9.jpg" + }, + "position": 3, + "published_at": "2026-07-13T15:24:00Z" + } + }, + { + "table": "story", + "binding": "marco_story", + "fields": { + "author": { + "ref": "marco" + }, + "caption": "Last night at camp", + "id": "30000000-0000-4000-8000-000000000004", + "media_alt": "Campfire on a bluff above the break", + "media_file": { + "file": "./seed/media-marco-baja-2.jpg" + }, + "position": 1, + "published_at": "2026-07-13T14:40:00Z" + } + }, + { + "table": "story", + "binding": "marco_story_swell", + "fields": { + "author": { + "ref": "marco" + }, + "caption": "It finally arrived", + "id": "30000000-0000-4000-8000-000000000012", + "media_alt": "Long left-hand wave peeling along a desert point", + "media_file": { + "file": "./seed/media-marco-baja-1.jpg" + }, + "position": 2, + "published_at": "2026-07-13T14:52:00Z" + } + }, + { + "table": "story", + "binding": "marco_story_dawn", + "fields": { + "author": { + "ref": "marco" + }, + "caption": "Pack up before the wind", + "id": "30000000-0000-4000-8000-000000000013", + "media_alt": "Surfboard fins silhouetted against a Baja sunrise", + "media_file": { + "file": "./seed/media-marco-baja-3.jpg" + }, + "position": 3, + "published_at": "2026-07-13T15:04:00Z" + } + }, + { + "table": "story", + "binding": "priya_story", + "fields": { + "author": { + "ref": "priya" + }, + "caption": "Still warm", + "id": "30000000-0000-4000-8000-000000000005", + "media_alt": "Freshly sliced sourdough loaf", + "media_file": { + "file": "./seed/media-priya-starter.jpg" + }, + "position": 1, + "published_at": "2026-07-13T14:20:00Z" + } + }, + { + "table": "story", + "binding": "june_story", + "fields": { + "author": { + "ref": "june" + }, + "caption": "Fitting day", + "id": "30000000-0000-4000-8000-000000000006", + "media_alt": "Linen garments arranged by shade", + "media_file": { + "file": "./seed/media-june-lookbook.jpg" + }, + "position": 1, + "published_at": "2026-07-13T13:50:00Z" + } + }, + { + "table": "story", + "binding": "kenji_story", + "fields": { + "author": { + "ref": "kenji" + }, + "caption": "Worth the climb", + "id": "30000000-0000-4000-8000-000000000007", + "media_alt": "Road bike at Copper Pass", + "media_file": { + "file": "./seed/media-kenji-copper.jpg" + }, + "position": 1, + "published_at": "2026-07-13T13:15:00Z" + } + }, + { + "table": "story_view", + "fields": { + "story": { + "ref": "mira_story" + }, + "viewer": { + "ref": "mira" + } + } + }, + { + "table": "story_view", + "fields": { + "story": { + "ref": "mira_story_tram" + }, + "viewer": { + "ref": "mira" + } + } + }, + { + "table": "story_view", + "fields": { + "story": { + "ref": "mira_story_market" + }, + "viewer": { + "ref": "mira" + } + } + }, + { + "table": "story_view", + "fields": { + "story": { + "ref": "priya_story" + }, + "viewer": { + "ref": "mira" + } + } + }, + { + "table": "story_view", + "fields": { + "story": { + "ref": "kenji_story" + }, + "viewer": { + "ref": "mira" + } + } + }, + { + "table": "post", + "binding": "lena_glaze", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.", + "id": "20000000-0000-4000-8000-000000000001", + "media_alt": "Grid of copper-red glaze test tiles on a maple bench", + "media_file": { + "file": "./seed/media-lena-glaze.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-13T13:00:00Z" + } + }, + { + "table": "post", + "binding": "marco_baja", + "fields": { + "author": { + "ref": "marco" + }, + "caption": "Three days down the Baja coast. Swell arrived on the last morning, as it always does.", + "id": "20000000-0000-4000-8000-000000000002", + "media_kind": "carousel", + "published_at": "2026-07-13T10:00:00Z" + } + }, + { + "table": "post", + "binding": "nils_aurora", + "fields": { + "author": { + "ref": "nils" + }, + "caption": "Aurora over the fjord last night — the whole sky was breathing.", + "id": "20000000-0000-4000-8000-000000000003", + "media_alt": "Green aurora curtains over a dark fjord", + "media_file": { + "file": "./seed/media-nils-aurora-poster.jpg" + }, + "media_kind": "video", + "published_at": "2026-07-13T06:00:00Z", + "video_file": { + "file": "./seed/media-nils-aurora.mp4" + } + } + }, + { + "table": "post", + "binding": "priya_starter", + "fields": { + "author": { + "ref": "priya" + }, + "caption": "Day 400 of the starter. She's earned a name: Clint Yeastwood.", + "id": "20000000-0000-4000-8000-000000000004", + "media_alt": "Open crumb of a sourdough loaf, sliced on a flour-dusted board", + "media_file": { + "file": "./seed/media-priya-starter.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-12T23:00:00Z" + } + }, + { + "table": "post", + "binding": "ayla_ferry", + "fields": { + "author": { + "ref": "ayla" + }, + "caption": "Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon.", + "id": "20000000-0000-4000-8000-000000000005", + "media_alt": "Ferry deck railing over blue water, city skyline behind", + "media_file": { + "file": "./seed/media-ayla-ferry.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-12T16:00:00Z" + } + }, + { + "table": "post", + "binding": "june_lookbook", + "fields": { + "author": { + "ref": "june" + }, + "caption": "Studio lookbook, page one. Linen in every weight we could mill.", + "id": "20000000-0000-4000-8000-000000000006", + "media_alt": "Folded linen garments stacked by shade on a workbench", + "media_file": { + "file": "./seed/media-june-lookbook.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-12T10:00:00Z" + } + }, + { + "table": "post", + "binding": "theo_court", + "fields": { + "author": { + "ref": "theo" + }, + "caption": "Finished the mural at the 9th street court. Paint holds up better than my jumper.", + "id": "20000000-0000-4000-8000-000000000007", + "media_alt": "Basketball court painted with bold geometric shapes", + "media_file": { + "file": "./seed/media-theo-court.jpg" + }, + "media_kind": "video", + "published_at": "2026-07-11T17:00:00Z", + "video_file": { + "file": "./seed/media-theo-court.mp4" + } + } + }, + { + "table": "post", + "binding": "kenji_copper", + "fields": { + "author": { + "ref": "kenji" + }, + "caption": "120km of switchbacks and one very smug goat. Copper Pass, you were worth it.", + "id": "20000000-0000-4000-8000-000000000008", + "media_alt": "Road bike leaning on a stone wall at a mountain pass", + "media_file": { + "file": "./seed/media-kenji-copper.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-11T11:00:00Z" + } + }, + { + "table": "post", + "binding": "lena_bowls", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Copper glaze in close-up, before the wax cooled.", + "id": "21000000-0000-4000-8000-000000000001", + "media_alt": "Copper-red glaze tiles", + "media_file": { + "file": "./seed/thumb-lena-1.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-10T14:00:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_greenware", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "A quiet stack of bowls waiting for bisque firing.", + "id": "21000000-0000-4000-8000-000000000002", + "media_alt": "Stack of unglazed bowls", + "media_file": { + "file": "./seed/thumb-lena-2.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-08T16:00:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_kiln", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Kiln Tetris, level thirty-seven.", + "id": "21000000-0000-4000-8000-000000000003", + "media_alt": "Kiln shelf mid-load", + "media_file": { + "file": "./seed/thumb-lena-3.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-05T11:30:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_celadon", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Celadon tests after a slower cool-down.", + "id": "21000000-0000-4000-8000-000000000004", + "media_alt": "Celadon test cups", + "media_file": { + "file": "./seed/thumb-lena-4.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-02T09:15:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_clay", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Fresh reclaim, wedged and ready for tomorrow.", + "id": "21000000-0000-4000-8000-000000000005", + "media_alt": "Wedging table with fresh clay", + "media_file": { + "file": "./seed/thumb-lena-5.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-28T18:40:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_plates", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Dinner plates with just enough iron speckle.", + "id": "21000000-0000-4000-8000-000000000006", + "media_alt": "Iron-speckled dinner plates", + "media_file": { + "file": "./seed/thumb-lena-6.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-24T13:00:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_throwing", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Pulling one tall cylinder before lunch.", + "id": "21000000-0000-4000-8000-000000000007", + "media_alt": "Throwing a tall cylinder", + "media_file": { + "file": "./seed/thumb-lena-7.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-19T16:20:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_buckets", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Labelling day. Future me will be grateful.", + "id": "21000000-0000-4000-8000-000000000008", + "media_alt": "Glaze buckets labelled by cone", + "media_file": { + "file": "./seed/thumb-lena-8.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-12T10:10:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "lena_morning", + "fields": { + "author": { + "ref": "lena" + }, + "caption": "Seven o'clock light across the clean bench.", + "id": "21000000-0000-4000-8000-000000000009", + "media_alt": "Morning light across the studio bench", + "media_file": { + "file": "./seed/thumb-lena-9.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-03T14:05:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "mira_pasteis", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "The batch that vanished before I finished the coffee.", + "id": "22000000-0000-4000-8000-000000000001", + "media_alt": "Pastéis de nata on a marble counter", + "media_file": { + "file": "./seed/thumb-mira-1.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-09T07:30:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "mira_tram", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "Rails holding the first light on Rua da Conceição.", + "id": "22000000-0000-4000-8000-000000000002", + "media_alt": "Tram rails catching dawn light", + "media_file": { + "file": "./seed/thumb-mira-2.jpg" + }, + "media_kind": "image", + "published_at": "2026-07-04T05:45:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "mira_citrus", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "Saturday citrus, arranged better than any still life.", + "id": "22000000-0000-4000-8000-000000000003", + "media_alt": "Market citrus stacked in crates", + "media_file": { + "file": "./seed/thumb-mira-3.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-29T08:20:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "mira_tiles", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "Blue after blue after blue.", + "id": "22000000-0000-4000-8000-000000000004", + "media_alt": "Tiled facade in alternating blues", + "media_file": { + "file": "./seed/thumb-mira-4.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-21T15:10:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "mira_sardines", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "Sardines, smoke, lemon. Nothing else needed.", + "id": "22000000-0000-4000-8000-000000000005", + "media_alt": "Grilled sardines over coals", + "media_file": { + "file": "./seed/thumb-mira-5.jpg" + }, + "media_kind": "image", + "published_at": "2026-06-13T20:15:00Z", + "show_in_feed": false + } + }, + { + "table": "post", + "binding": "mira_ferry", + "fields": { + "author": { + "ref": "mira" + }, + "caption": "The last ferry left a gold line all the way home.", + "id": "22000000-0000-4000-8000-000000000006", + "media_alt": "Ferry wake at golden hour", + "media_file": { + "file": "./seed/thumb-mira-6.jpg" + }, + "media_kind": "video", + "published_at": "2026-06-01T19:50:00Z", + "show_in_feed": false, + "video_file": { + "file": "./seed/media-mira-ferry.mp4" + } + } + }, + { + "table": "post_tag", + "fields": { + "person": { + "ref": "mira" + }, + "post": { + "ref": "marco_baja" + } + } + }, + { + "table": "post_tag", + "fields": { + "person": { + "ref": "ayla" + }, + "post": { + "ref": "june_lookbook" + } + } + }, + { + "table": "post_tag", + "fields": { + "person": { + "ref": "june" + }, + "post": { + "ref": "theo_court" + } + } + }, + { + "table": "post_tag", + "fields": { + "person": { + "ref": "lena" + }, + "post": { + "ref": "priya_starter" + } + } + }, + { + "table": "post_tag", + "fields": { + "person": { + "ref": "marco" + }, + "post": { + "ref": "kenji_copper" + } + } + }, + { + "table": "post_tag", + "fields": { + "person": { + "ref": "theo" + }, + "post": { + "ref": "lena_glaze" + } + } + }, + { + "table": "carousel_slide", + "fields": { + "alt": "Long left-hand wave peeling along a desert point", + "file": { + "file": "./seed/media-marco-baja-1.jpg" + }, + "position": 1, + "post": { + "ref": "marco_baja" + } + } + }, + { + "table": "carousel_slide", + "fields": { + "alt": "Campfire on the bluff above the break at dusk", + "file": { + "file": "./seed/media-marco-baja-2.jpg" + }, + "position": 2, + "post": { + "ref": "marco_baja" + } + } + }, + { + "table": "carousel_slide", + "fields": { + "alt": "Board fins silhouetted against the sunrise", + "file": { + "file": "./seed/media-marco-baja-3.jpg" + }, + "position": 3, + "post": { + "ref": "marco_baja" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "kenji" + }, + "body": "That copper red is unreal. What cone are you firing to?", + "created_at": "2026-07-13T13:28:00Z", + "post": { + "ref": "lena_glaze" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "priya" + }, + "body": "The third tile down — that speckle! Saving this for glaze inspiration.", + "created_at": "2026-07-13T13:35:00Z", + "post": { + "ref": "lena_glaze" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "june" + }, + "body": "Would buy the whole batch honestly. Seconds sale when?", + "created_at": "2026-07-13T13:42:00Z", + "post": { + "ref": "lena_glaze" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "theo" + }, + "body": "These would look wild as a court-side mosaic. Collab?", + "created_at": "2026-07-13T13:51:00Z", + "post": { + "ref": "lena_glaze" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "kenji" + }, + "body": "That road looks nearly as good as the wave.", + "created_at": "2026-07-13T10:18:00Z", + "post": { + "ref": "marco_baja" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "ayla" + }, + "body": "Frame two belongs on a wall.", + "created_at": "2026-07-13T10:26:00Z", + "post": { + "ref": "marco_baja" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "mira" + }, + "body": "The reflection makes this. Beautiful.", + "created_at": "2026-07-13T06:22:00Z", + "post": { + "ref": "nils_aurora" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "lena" + }, + "body": "Clint looks extremely healthy.", + "created_at": "2026-07-13T12:40:00Z", + "post": { + "ref": "priya_starter" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "theo" + }, + "body": "I volunteer for quality control.", + "created_at": "2026-07-13T13:05:00Z", + "post": { + "ref": "priya_starter" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "mira" + }, + "body": "That blue is perfect.", + "created_at": "2026-07-13T06:00:00Z", + "post": { + "ref": "ayla_ferry" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "ayla" + }, + "body": "The warm grey set, please.", + "created_at": "2026-07-12T22:35:00Z", + "post": { + "ref": "june_lookbook" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "june" + }, + "body": "This completely changes the block.", + "created_at": "2026-07-12T06:10:00Z", + "post": { + "ref": "theo_court" + } + } + }, + { + "table": "comment", + "fields": { + "author": { + "ref": "marco" + }, + "body": "Goat photo or it did not happen.", + "created_at": "2026-07-12T00:20:00Z", + "post": { + "ref": "kenji_copper" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "marco" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "nils" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "ayla" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "theo" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "kenji" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "marco_baja" + }, + "user": { + "ref": "lena" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "marco_baja" + }, + "user": { + "ref": "nils" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "marco_baja" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "marco_baja" + }, + "user": { + "ref": "ayla" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "marco_baja" + }, + "user": { + "ref": "kenji" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "nils_aurora" + }, + "user": { + "ref": "lena" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "nils_aurora" + }, + "user": { + "ref": "marco" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "nils_aurora" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "nils_aurora" + }, + "user": { + "ref": "ayla" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "nils_aurora" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "priya_starter" + }, + "user": { + "ref": "lena" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "priya_starter" + }, + "user": { + "ref": "marco" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "priya_starter" + }, + "user": { + "ref": "nils" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "priya_starter" + }, + "user": { + "ref": "ayla" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "priya_starter" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "priya_starter" + }, + "user": { + "ref": "theo" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "priya_starter" + }, + "user": { + "ref": "kenji" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "ayla_ferry" + }, + "user": { + "ref": "lena" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "ayla_ferry" + }, + "user": { + "ref": "marco" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "ayla_ferry" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "ayla_ferry" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "june_lookbook" + }, + "user": { + "ref": "lena" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "june_lookbook" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "june_lookbook" + }, + "user": { + "ref": "ayla" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "june_lookbook" + }, + "user": { + "ref": "theo" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "theo_court" + }, + "user": { + "ref": "marco" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "theo_court" + }, + "user": { + "ref": "ayla" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "theo_court" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "theo_court" + }, + "user": { + "ref": "kenji" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "mira" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "lena" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "marco" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "nils" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "ayla" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "kenji_copper" + }, + "user": { + "ref": "theo" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_bowls" + }, + "user": { + "ref": "mira" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_bowls" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_greenware" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "lena_kiln" + }, + "user": { + "ref": "theo" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "mira_pasteis" + }, + "user": { + "ref": "lena" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "mira_pasteis" + }, + "user": { + "ref": "priya" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "mira_tram" + }, + "user": { + "ref": "june" + } + } + }, + { + "table": "like", + "fields": { + "post": { + "ref": "mira_ferry" + }, + "user": { + "ref": "kenji" + } + } + }, + { + "table": "save", + "fields": { + "post": { + "ref": "marco_baja" + }, + "user": { + "ref": "mira" + } + } + }, + { + "table": "save", + "fields": { + "post": { + "ref": "nils_aurora" + }, + "user": { + "ref": "mira" + } + } + }, + { + "table": "save", + "fields": { + "post": { + "ref": "lena_glaze" + }, + "user": { + "ref": "priya" + } + } + } + ] +} \ No newline at end of file diff --git a/crates/spock-cli/tests/provider_fixtures/dispatch-switch.ts b/crates/spock-cli/tests/provider_fixtures/dispatch-switch.ts new file mode 100644 index 0000000..d4e679e --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/dispatch-switch.ts @@ -0,0 +1,70 @@ + case "SetLike": + return { + request, + operation: { + kind: "set_like", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + liked: boolValue(requiredField(fields, "liked")), + }, + }; + case "SetSave": + return { + request, + operation: { + kind: "set_save", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + saved: boolValue(requiredField(fields, "saved")), + }, + }; + case "LoadMore": + return { request, operation: { kind: "load_more" } }; + case "ReloadFeed": + return { request, operation: { kind: "reload_feed" } }; + case "SetFollow": + return { + request, + operation: { + kind: "set_follow", + user: keyText(requiredField(fields, "user"), USER_ID_TYPE), + following: boolValue(requiredField(fields, "following")), + }, + }; + case "AddComment": + return { + request, + operation: { + kind: "add_comment", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + body: textValue(requiredField(fields, "body")), + }, + }; + case "SearchPeople": + return { + request, + operation: { + kind: "search_people", + query: textValue(requiredField(fields, "query")), + }, + }; + case "ChooseImage": + return { request, operation: { kind: "choose_image_request" } }; + case "PublishImage": + return { + request, + operation: { + kind: "publish_image_request", + object: textValue(requiredField(fields, "object")), + caption: textValue(requiredField(fields, "caption")), + alt: textValue(requiredField(fields, "alt")), + }, + }; + case "MarkStory": + return { + request, + operation: { + kind: "mark_story", + story: keyText(requiredField(fields, "story"), STORY_ID_TYPE), + }, + }; + default: + throw new TypeError(`unsupported Instagram mutation \`${mutation}\``); diff --git a/crates/spock-cli/tests/provider_fixtures/instagram.wire b/crates/spock-cli/tests/provider_fixtures/instagram.wire new file mode 100644 index 0000000..d3c87ff --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/instagram.wire @@ -0,0 +1,90 @@ +// instagram.wire — v0.1 (파서 대상) +// 역할: Spock 스키마를 재선언하지 않고(이중 권위 금지) 그 위의 +// 투영·계약 계층을 선언한다. v0.1 생성물 = 기계 측 계약 타입. + +use contract "contract.json"; // spock build 산출물 (또는 GET /~contract) +app "Instagram"; + +snapshot app { + cap 200 per table; + read user, story, story_view, post, carousel_slide as slides, + comment, like, save, follow, post_tag; +} + +view Post from post { + id; + author -> User; + caption; + media = match media_kind { + "image" => Image { image: media_file as image }, + "carousel" => Carousel { images: each carousel_slide.file as image order by position }, + "video" => Video { src: video_file as url, poster: media_file as image }, + }; + like_count = count like where post = id; + comment_count = count comment where post = id; + viewer_liked = exists like where post = id and user = viewer; + viewer_saved = exists save where post = id and user = viewer; + posted_label = ago(published_at); +} + +view Profile from user { + user = row as User; + bio; + post_count = count post where author = id and show_in_feed; + follower_count = count follow where followed = id; + following_count = count follow where follower = id; + viewer_follows = exists follow where follower = viewer and followed = id; + posts = tiles of post where author = id and show_in_feed; + reels = tiles of post where author = id and media_kind is video; + tagged = tiles of post_tag where person = id; + saved = tiles of save where user = id; +} + +mutation SetLike { post: post.id, liked: bool } + -> if liked call like_post(post) route feed/like-post allow not_authorized, not_found + else call unlike_post(post) route feed/unlike-post allow not_authorized; + +mutation SetSave { post: post.id, saved: bool } + -> if saved call save_post(post) route feed/save-post allow not_authorized, not_found + else call unsave_post(post) route feed/unsave-post allow not_authorized; + +mutation LoadMore + -> local { window feed += 4; resnapshot; }; + +mutation ReloadFeed + -> local { window feed = 4; resnapshot; }; + +mutation SetFollow { user: user.id, following: bool } + -> if following call follow_user(user) route profile/follow-user allow not_authorized, not_found, cannot_follow_self + else call unfollow_user(user) route profile/unfollow-user allow not_authorized; + +mutation AddComment { post: post.id, body: text } + -> call add_comment(post, body) route comments/add-comment allow not_authorized, comment_body_invalid, not_found; + +mutation SearchPeople { query: text } + -> local { resnapshot; filter people by query; }; + +mutation ChooseImage op choose_image_request + -> host { pick_file; check type in (jpeg, png, webp); upload; settle ImageReady; }; + +mutation PublishImage op publish_image_request { object: text, caption: text, alt: text } + -> call create_image_post(object, caption, alt) route create/publish-image allow not_authorized, image_not_ready, unsupported_media_type; + +mutation MarkStory { story: story.id } + -> call mark_story_viewed(story) route feed/mark-story-seen allow not_authorized, not_found; + +settlement { + ok => Accepted after resnapshot; + refusal => Refused { reason: kebab(error.code) }; + extra ImageReady { object: text, preview: text, name: text }; + timeout 15s; + no retry; +} + +fixtures from spock.seed { + assets capture as webp under logical names; + video "video-nils-aurora" = "media-nils-aurora.mp4"; + video "video-theo-court" = "media-theo-court.mp4"; + video "video-mira-ferry" = "media-mira-ferry.mp4"; + emit Observation.fixture, RequestPort.fixture; +} diff --git a/crates/spock-cli/tests/provider_fixtures/machine-types.uhura b/crates/spock-cli/tests/provider_fixtures/machine-types.uhura new file mode 100644 index 0000000..d572bf8 --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/machine-types.uhura @@ -0,0 +1,44 @@ +pub enum Mutation { + SetLike { + post: PostId, + liked: Bool, + }, + SetSave { + post: PostId, + saved: Bool, + }, + LoadMore, + ReloadFeed, + SetFollow { + user: UserId, + following: Bool, + }, + AddComment { + post: PostId, + body: Text, + }, + SearchPeople { + query: Text, + }, + ChooseImage, + PublishImage { + object: Text, + caption: Text, + alt: Text, + }, + MarkStory { + story: StoryId, + }, +} + +pub enum Settlement { + Accepted, + Refused { + reason: Text, + }, + ImageReady { + object: Text, + preview: Text, + name: Text, + }, +} diff --git a/crates/spock-cli/tests/provider_fixtures/manifest.toml b/crates/spock-cli/tests/provider_fixtures/manifest.toml new file mode 100644 index 0000000..ca05887 --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/manifest.toml @@ -0,0 +1,531 @@ +# Materialized Grida Library assets for the Instagram demo. +# +# Uhura Editor embeds these local bytes as data URIs and Uhura Play serves +# them from /api/play/assets, so remote URLs are provenance only: the demo +# remains deterministic and works offline. Every normalized WebP is square, +# metadata-free, encoded at quality 82, and pinned by SHA-256. Grida Library terms: +# https://grida.co/library/license +# +# `cargo run -p uhura-cli --bin gen-assets -- examples/instagram/client` +# validates and preserves sourced files; it only renders declared motif entries. + +[assets.avatar-mira] +file = "avatar-mira.webp" +alt = "Mira Santos" +size = 96 +source = "2f59229e-289b-4516-961b-c04071cae97e" +sha256 = "acae81c0cab21c63bf14acbbad00f153ffce2e456a66565df76cd6a9fcaa07a7" + +[assets.avatar-lena] +file = "avatar-lena.webp" +alt = "Lena Holt" +size = 96 +source = "9aeef2ca-e5a6-4e57-aa62-7aaab40b12ac" +sha256 = "48563b24e12ad4f43294683e83d43d672678fba84c1095e60f5133d5e652b8af" + +[assets.avatar-marco] +file = "avatar-marco.webp" +alt = "Marco Reyes" +size = 96 +source = "91d0101c-747d-494d-a486-6bc1831ccdc1" +sha256 = "6d85aa8d94ebbc5b8a08c21078c8179c04eb6d0c7d21ac68441601e7e5dd8e2d" + +[assets.avatar-nils] +file = "avatar-nils.webp" +alt = "Nils Bergman" +size = 96 +source = "3576d1ef-dd7e-4139-98d0-4f655c3e7632" +sha256 = "fca86e95abf9a2c67ef7455de9011fe69c3e66422b3588412420c2d328a670c0" + +[assets.avatar-priya] +file = "avatar-priya.webp" +alt = "Priya Raman" +size = 96 +source = "5a74d96e-afff-4c4f-97c0-6f414c68228b" +sha256 = "eaa15bffd6daaf007f947c6f7835bbef81b6dad30cb7f2db2f1b55d2954c7e0a" + +[assets.avatar-ayla] +file = "avatar-ayla.webp" +alt = "Ayla Demir" +size = 96 +source = "f3c4f2f4-96ef-4879-8fb5-af957df040a5" +sha256 = "28518b941990fd2863becb348cbb737c3505359a6dde949240d1987ec03630c0" + +[assets.avatar-june] +file = "avatar-june.webp" +alt = "June Park" +size = 96 +source = "fa67ad53-50bd-4560-b549-394bad8a432c" +sha256 = "e41031b96eb192c18f231fbb80769da725cbf2091ffed2a6a7b04506ac925e25" + +[assets.avatar-theo] +file = "avatar-theo.webp" +alt = "Theo Okafor" +size = 96 +source = "76034717-f3c2-4f41-8606-f7bc0f5453b3" +sha256 = "860e4b2bb8f86b4e4871a0d490ad4cfbb91c4d59655765956b9856665e5fdbe1" + +[assets.avatar-kenji] +file = "avatar-kenji.webp" +alt = "Kenji Tanaka" +size = 96 +source = "ffa8705a-ac74-4348-8208-e139a9d95126" +sha256 = "f85504648e4f76e2ba6eca84e5c998341014d87030ec21b2266960f0ef8bb744" + +[assets.media-lena-glaze] +file = "media-lena-glaze.webp" +alt = "Decorative ceramic tile panels leaning in an artisan studio" +size = 640 +source = "79ea4b31-10e5-4bc4-8cbf-2abf7f0af8e9" +sha256 = "1431e6fa2192a294e01dcf8ab057ec62197823645a163b9cd5aae0f06444d27d" + +[assets.media-marco-baja-1] +file = "media-marco-baja-1.webp" +alt = "Ocean wave exploding into white spray against deep blue water" +size = 640 +source = "e59fb89a-695d-4988-934b-68527df3a40a" +sha256 = "b28170eb3865d3c3a926e57849ad756a2a7ca0ebf611bfc8d8745a7796161fdc" + +[assets.media-marco-baja-2] +file = "media-marco-baja-2.webp" +alt = "Glowing campfire beside a tent under a desert night sky" +size = 640 +source = "acd39bc1-dca3-4ea4-b74b-f489618c9676" +sha256 = "bb0052f57117cb9746b0676b793c6fe3759c1681b8a1b3cec1a6960e9f296fb0" + +[assets.media-marco-baja-3] +file = "media-marco-baja-3.webp" +alt = "Palm-lined ocean glowing orange and violet at sunset" +size = 640 +source = "9085e308-d9ec-46d8-bf38-3bbb0b1238af" +sha256 = "2f5b2a74c6d6458f9f710003053e0bfe22467e3713db0c6334f1e3758ac6e883" + +[assets.media-nils-aurora-poster] +file = "media-nils-aurora-poster.webp" +alt = "Soft bands of blue, violet, and green light across a dark sky" +size = 640 +source = "594e83dc-0f30-49ce-a5cb-4e2a6e531c7c" +sha256 = "c203b6d60042a3503a8c8157832a326db344c4cdf8fb6a12e31b7c83aa09a9e6" + +[assets.media-priya-starter] +file = "media-priya-starter.webp" +alt = "Black-and-white cross-section of a rustic bread loaf" +size = 640 +source = "c90aed47-5b22-47a7-b1bb-527d0ca472fb" +sha256 = "bc1e0e6cbe4e991d71fb2f4157dd2c2e5648a484c42084bbccbd04f7dfea3b73" + +[assets.media-ayla-ferry] +file = "media-ayla-ferry.webp" +alt = "Small boat crossing blue water toward the Jaffa skyline" +size = 640 +source = "9580a2e2-4792-4349-9bf8-042dacb2ca18" +sha256 = "cdccd6befe9f7c007b4b0150c60f3cd5d701f0488fd7ee32b02744c77f95c321" + +[assets.media-june-lookbook] +file = "media-june-lookbook.webp" +alt = "Navy mosaic printed across natural linen fabric" +size = 640 +source = "5f91e814-0ffd-4fbf-b5f9-87aa8765dc50" +sha256 = "114156cf5b9879688c28ec7d1c7f023a6655bade774d4aa0dbd5a758a6c5033b" + +[assets.media-theo-court] +file = "media-theo-court.webp" +alt = "Colorful patterned staircase framed by saturated yellow walls" +size = 640 +source = "4f15c848-9587-49c0-8585-11b46a0ee8db" +sha256 = "ea65673a673b538e431ba4e3d0cd81a49b0b4144c3fb26bbbea1baec4e7f9bcf" + +[assets.media-kenji-copper] +file = "media-kenji-copper.webp" +alt = "Cyclists riding through a crowded market square" +size = 640 +source = "69083e76-d2f3-4d72-9c52-a9e62eb8476d" +sha256 = "02c48eee5a8d76c6c8bffaa1b71d2c0a9773c685ac8d5adb99ad41d88341a358" + +[assets.thumb-lena-1] +file = "thumb-lena-1.webp" +alt = "Decorative ceramic tile panels in warm glaze colors" +size = 320 +source = "79ea4b31-10e5-4bc4-8cbf-2abf7f0af8e9" +sha256 = "481a25bff6f9027e85f4ea82d07c4a131d3b2da8fc3c2c1e139c8aa6ac6b009f" + +[assets.thumb-lena-2] +file = "thumb-lena-2.webp" +alt = "Celadon ceramic vessel with a sculpted wave rim" +size = 320 +source = "ec36ef84-8266-43de-921c-48e9531d96b1" +sha256 = "995a6f1cfb2bcadc81fd44d0250933cec0721aad486ec3b300f3cfe589dd8ad0" + +[assets.thumb-lena-3] +file = "thumb-lena-3.webp" +alt = "Editorial catalog of handmade stoneware ceramics" +size = 320 +source = "6b040e86-a7f4-4847-bf7e-dd542a58f40e" +sha256 = "0869effd4eb8160cc0a314d340a9c45e1e9f0235087fe005d3f29f175fcd0c3b" + +[assets.thumb-lena-4] +file = "thumb-lena-4.webp" +alt = "White ceramic vessel with a flowing sculptural form" +size = 320 +source = "9e6eaf2a-fdb5-4235-b2c6-5aa9ba6f0160" +sha256 = "496d22b701356f30bd38349c745d6646684d6f163b879a1b7269da39bdd40f45" + +[assets.thumb-lena-5] +file = "thumb-lena-5.webp" +alt = "Miniature artist studio with shelves and a workbench" +size = 320 +source = "dce54a9f-c54c-4d65-ab0e-a99df3c95462" +sha256 = "4fa3e8eb0f3a12a832545b9943a3b00d356d40ebe37cf5733b234b7a8d64613e" + +[assets.thumb-lena-6] +file = "thumb-lena-6.webp" +alt = "Blush ceramic sculpture with a looping organic form" +size = 320 +source = "25cb2f23-daae-430e-81b4-bf30b2a336ab" +sha256 = "bf19a0cf6095a3fd5eddf9aacd946b08a5bf367b1e1716b2f62473bd2e3c2ba1" + +[assets.thumb-lena-7] +file = "thumb-lena-7.webp" +alt = "Glossy ceramic head with small floral decorations" +size = 320 +source = "4e0a9076-f9d3-465b-bd1b-0ab1483ca1d9" +sha256 = "59cda68ff20c98a7507667e2a41475f0375f03acf8cadfbf4dd9bf5b933d2b74" + +[assets.thumb-lena-8] +file = "thumb-lena-8.webp" +alt = "Two painted ceramic vases filled with flowers" +size = 320 +source = "86cc66c2-f78d-4142-9584-6244f520e396" +sha256 = "d5895e2a5538cb62ebd699ed5d777831246b2b7929a90d3b7fa741375cc13778" + +[assets.thumb-lena-9] +file = "thumb-lena-9.webp" +alt = "Artisan material study arranged on a dark catalog page" +size = 320 +source = "55faa69a-e1f6-4815-b6f0-0e4e20b8cae0" +sha256 = "b9c8076aad65e468eead20f68d5be0cbade06924f79d8782db4d75f6268bbe85" + +[assets.thumb-mira-1] +file = "thumb-mira-1.webp" +alt = "Translucent citrus and radish slices on white" +size = 320 +source = "b95e551c-f7bf-44b9-bef5-fc1a584d8684" +sha256 = "425e9d633d178a5ab134bd1a00caaa479b2e5c48ae3f5e1372687e93bf49c2f5" + +[assets.thumb-mira-2] +file = "thumb-mira-2.webp" +alt = "Colorful geometric Mediterranean hillside village" +size = 320 +source = "0426d0d3-84df-4145-adef-2d0f48c6de49" +sha256 = "2ce92010b2d4f26198454abbae66c4963715f1c265719a515381ed4e0eb9f0e2" + +[assets.thumb-mira-3] +file = "thumb-mira-3.webp" +alt = "Grid of cross-sectioned fruits and vegetables" +size = 320 +source = "b47fd9c7-390f-4917-b3ea-a24081b44662" +sha256 = "8129678f486e6ace998afbf5f7cb8a00be69b6c9230d40f68ea5852054d86dd6" + +[assets.thumb-mira-4] +file = "thumb-mira-4.webp" +alt = "Colorful patterned staircase framed by yellow walls" +size = 320 +source = "4f15c848-9587-49c0-8585-11b46a0ee8db" +sha256 = "535d3e9d7dcd68b6ad26c4f432763c92e788b6f95fd7d210b9611be8fb216ea5" + +[assets.thumb-mira-5] +file = "thumb-mira-5.webp" +alt = "Bold illustrated food flavors in a four-panel grid" +size = 320 +source = "58e06e31-7747-4202-a7e9-732e93b04810" +sha256 = "9f81c78f25fc826fc6612c707e8ed622ab1da8aff7dd227b1d9e983c371f911f" + +[assets.thumb-mira-6] +file = "thumb-mira-6.webp" +alt = "Ocean spray breaking over rocks in golden light" +size = 320 +source = "2665c0c1-807d-4158-9d8a-48b2a559306a" +sha256 = "5901f2cb720222122303b307cea42625466de12293d8552cb59d41ae23235dfc" + +# Source objects are normalized once into the asset entries above; no runtime +# request is made to these URLs. `LicenseRef-GridaLibrary` means the custom +# no-attribution personal/commercial grant stated on Grida Library's license page. + +[sources.2f59229e-289b-4516-961b-c04071cae97e] +collection = "home" +page = "https://grida.co/library/o/2f59229e-289b-4516-961b-c04071cae97e" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/6b41eeafc2c1310a.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "6b41eeafc2c1310aefd2d6cb2e0a00312276acc9f2d3cf2aee89fee1069cccae" + +[sources.9aeef2ca-e5a6-4e57-aa62-7aaab40b12ac] +collection = "home" +page = "https://grida.co/library/o/9aeef2ca-e5a6-4e57-aa62-7aaab40b12ac" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/28ee1adef3887eca.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "28ee1adef3887eca2c82389840a1b341836fbde7d7975b9201694a4e1d906d25" + +[sources.91d0101c-747d-494d-a486-6bc1831ccdc1] +collection = "generated" +page = "https://grida.co/library/o/91d0101c-747d-494d-a486-6bc1831ccdc1" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/generated/a01ad4fb-c03d-4c6f-bf48-fd2694483732.png?download=" +license = "CC0-1.0" +generator = "gpt-image-1" +source-sha256 = "5671a37b123c157446cbdb1ab736d9715a1bf88f30d2a3280e7c7da6b053bd7a" + +[sources.3576d1ef-dd7e-4139-98d0-4f655c3e7632] +collection = "generated" +page = "https://grida.co/library/o/3576d1ef-dd7e-4139-98d0-4f655c3e7632" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/generated/946fee89-5b03-408b-b29c-41ad4c11ed74.png?download=" +license = "CC0-1.0" +generator = "gpt-image-1" +source-sha256 = "46b611bc242a3a5ce5d56ab61ec190236b1ee1f2a57fd5cb8c4e0dd2b4dcabc5" + +[sources.5a74d96e-afff-4c4f-97c0-6f414c68228b] +collection = "home" +page = "https://grida.co/library/o/5a74d96e-afff-4c4f-97c0-6f414c68228b" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/691e2b2cc34a408f.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "691e2b2cc34a408f8c3cd8d425316a67ac426e478ce7241694a86223925dceb3" + +[sources.fa67ad53-50bd-4560-b549-394bad8a432c] +collection = "home" +page = "https://grida.co/library/o/fa67ad53-50bd-4560-b549-394bad8a432c" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/bdcc24b2c6d9c275.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "bdcc24b2c6d9c27546f71bcc4b5cef4060d766bb7285c1f8bb10d8d52a92639d" + +[sources.76034717-f3c2-4f41-8606-f7bc0f5453b3] +collection = "generated" +page = "https://grida.co/library/o/76034717-f3c2-4f41-8606-f7bc0f5453b3" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/generated/61d80404-33a1-4a80-bc36-ddef7a3c0e5a.png?download=" +license = "CC0-1.0" +generator = "gpt-image-1" +source-sha256 = "48595e71b1d7dde12064af0132577763a7ee8a7dc388f50be498da95566defb8" + +[sources.79ea4b31-10e5-4bc4-8cbf-2abf7f0af8e9] +collection = "home" +page = "https://grida.co/library/o/79ea4b31-10e5-4bc4-8cbf-2abf7f0af8e9" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/cb5e48bf198a901c.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "cb5e48bf198a901cab098ccec51ac3343b0e8eff65a90248765fcae053522bbb" + +[sources.e59fb89a-695d-4988-934b-68527df3a40a] +collection = "home" +page = "https://grida.co/library/o/e59fb89a-695d-4988-934b-68527df3a40a" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/f165f2a27cb3784c.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "f165f2a27cb3784ce8df2a884e2fcfb390bea473cf15f0c1fbb42e48270a77fd" + +[sources.acd39bc1-dca3-4ea4-b74b-f489618c9676] +collection = "home" +page = "https://grida.co/library/o/acd39bc1-dca3-4ea4-b74b-f489618c9676" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/04231616cbc0468a.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "04231616cbc0468a09cfb6189b4996d34919eb6f5780b29343f8b273058b349b" + +[sources.9085e308-d9ec-46d8-bf38-3bbb0b1238af] +collection = "generated" +page = "https://grida.co/library/o/9085e308-d9ec-46d8-bf38-3bbb0b1238af" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/generated/91160813-3daa-4bd8-89ef-c04dcae2aa25.webp?download=" +license = "CC0-1.0" +generator = "black-forest-labs/flux-schnell" +source-sha256 = "94351b626f27e95e068e502feb9770e5a6ff9da0bec8731bb24e8bba8463aed2" + +[sources.594e83dc-0f30-49ce-a5cb-4e2a6e531c7c] +collection = "home" +page = "https://grida.co/library/o/594e83dc-0f30-49ce-a5cb-4e2a6e531c7c" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/4e3ab20795e27683.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "4e3ab20795e276837cebfb9c75b944a1138e201a757b6097fbf7cc4f1ebf39e2" + +[sources.c90aed47-5b22-47a7-b1bb-527d0ca472fb] +collection = "pdimagearchive" +page = "https://grida.co/library/o/c90aed47-5b22-47a7-b1bb-527d0ca472fb" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/pdimagearchive/316b7e62-9fb6-4cbd-a5f3-c64f086d0260.jpg?download=" +license = "CC0" +source-sha256 = "362e6f4fc0814b7071a4e767eee3deee939fc70e713a4692a97bf32623e0a76e" + +[sources.9580a2e2-4792-4349-9bf8-042dacb2ca18] +collection = "pdimagearchive" +page = "https://grida.co/library/o/9580a2e2-4792-4349-9bf8-042dacb2ca18" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/pdimagearchive/1ce6c187-16e2-4bdc-8cee-02dec1710fd6.jpg?download=" +license = "CC0" +source-sha256 = "707fc479b0e02e925f3fc12b3096ae96ecc609484150444a228314f61ba4eb11" + +[sources.5f91e814-0ffd-4fbf-b5f9-87aa8765dc50] +collection = "home" +page = "https://grida.co/library/o/5f91e814-0ffd-4fbf-b5f9-87aa8765dc50" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/9bf12ccc5bf6c05c.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "9bf12ccc5bf6c05cddf782bdc88837296aada96ef26aef511a67105852d4d248" + +[sources.4f15c848-9587-49c0-8585-11b46a0ee8db] +collection = "home" +page = "https://grida.co/library/o/4f15c848-9587-49c0-8585-11b46a0ee8db" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/942ddf30d06adc77.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "942ddf30d06adc77d1ee252017aceefa0adebc8f48bbced33ad1bceb9e16a436" + +[sources.69083e76-d2f3-4d72-9c52-a9e62eb8476d] +collection = "home" +page = "https://grida.co/library/o/69083e76-d2f3-4d72-9c52-a9e62eb8476d" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/4cb7300e3679a971.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "4cb7300e3679a9712f00c3ac3f3aa9711449e462e6b74c9aeac2bc3b7acd473c" + +[sources.ec36ef84-8266-43de-921c-48e9531d96b1] +collection = "home" +page = "https://grida.co/library/o/ec36ef84-8266-43de-921c-48e9531d96b1" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/49e5823193c6df03.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "49e5823193c6df030d5b67abcb21741f23e67538425a97722cc5b43934d4338e" + +[sources.6b040e86-a7f4-4847-bf7e-dd542a58f40e] +collection = "home" +page = "https://grida.co/library/o/6b040e86-a7f4-4847-bf7e-dd542a58f40e" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/d1ad3c81358d2ee0.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "d1ad3c81358d2ee07257a75b6a47d08113224a0e3a41abd284a9939cab59b4f2" + +[sources.9e6eaf2a-fdb5-4235-b2c6-5aa9ba6f0160] +collection = "home" +page = "https://grida.co/library/o/9e6eaf2a-fdb5-4235-b2c6-5aa9ba6f0160" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/4a1daa3112a89a17.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "4a1daa3112a89a17687a45335d668c19d600c9563d7deaa4d4d9400f2796f71d" + +[sources.dce54a9f-c54c-4d65-ab0e-a99df3c95462] +collection = "home" +page = "https://grida.co/library/o/dce54a9f-c54c-4d65-ab0e-a99df3c95462" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/2b66506ce3de72a4.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "2b66506ce3de72a48ae1e20d1f01135e77cc49aff9e320da54fa686b7fe2c02c" + +[sources.25cb2f23-daae-430e-81b4-bf30b2a336ab] +collection = "home" +page = "https://grida.co/library/o/25cb2f23-daae-430e-81b4-bf30b2a336ab" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/4e970be905ec0779.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "4e970be905ec0779de9046140195167b9e562ae19e47295a6c830f45b79a8ceb" + +[sources.4e0a9076-f9d3-465b-bd1b-0ab1483ca1d9] +collection = "home" +page = "https://grida.co/library/o/4e0a9076-f9d3-465b-bd1b-0ab1483ca1d9" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/61188c2716fbd108.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "61188c2716fbd108ff56ecf4aa72e10b61be38350bc350cf48b6310aaf3972e3" + +[sources.86cc66c2-f78d-4142-9584-6244f520e396] +collection = "home" +page = "https://grida.co/library/o/86cc66c2-f78d-4142-9584-6244f520e396" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/ec14ab6836ed7f1a.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "ec14ab6836ed7f1a8e20728efdb2bc6847429c4b04c9d64ac73b178a2d5b0e97" + +[sources.55faa69a-e1f6-4815-b6f0-0e4e20b8cae0] +collection = "home" +page = "https://grida.co/library/o/55faa69a-e1f6-4815-b6f0-0e4e20b8cae0" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/74251fddca05f4f8.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "74251fddca05f4f80e36b3681859266db1eca99e3975ba6e7e458c0c0265048c" + +[sources.b95e551c-f7bf-44b9-bef5-fc1a584d8684] +collection = "home" +page = "https://grida.co/library/o/b95e551c-f7bf-44b9-bef5-fc1a584d8684" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/5be1627235e0262a.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "5be1627235e0262af55a8b763d75ca8a81bc82f8ac0b3e3a1c8b9494547473f7" + +[sources.0426d0d3-84df-4145-adef-2d0f48c6de49] +collection = "home" +page = "https://grida.co/library/o/0426d0d3-84df-4145-adef-2d0f48c6de49" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/dcb059d8733ffa68.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "dcb059d8733ffa6887dd8704100239c84264adc137f15c42e4a29631fd6bcf9e" + +[sources.b47fd9c7-390f-4917-b3ea-a24081b44662] +collection = "home" +page = "https://grida.co/library/o/b47fd9c7-390f-4917-b3ea-a24081b44662" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/67af1e7b6d133905.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "67af1e7b6d1339050448408f278acfc98c5452cebff034a4994533133a49446a" + +[sources.58e06e31-7747-4202-a7e9-732e93b04810] +collection = "home" +page = "https://grida.co/library/o/58e06e31-7747-4202-a7e9-732e93b04810" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/a8ce2ee46be18714.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "a8ce2ee46be1871465332ef73478a97d5d96dc68bdcea4fa58110aa1594550a7" + +[sources.2665c0c1-807d-4158-9d8a-48b2a559306a] +collection = "home" +page = "https://grida.co/library/o/2665c0c1-807d-4158-9d8a-48b2a559306a" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/b29fd6025e5c4906.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "b29fd6025e5c4906edfa8df2f615949d61e328babe3a7014dd8989ff61cc7c3d" + +[sources.f3c4f2f4-96ef-4879-8fb5-af957df040a5] +collection = "home" +page = "https://grida.co/library/o/f3c4f2f4-96ef-4879-8fb5-af957df040a5" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/home/6a1e81834e70f860.webp?download=" +license = "LicenseRef-GridaLibrary" +author = "Grida" +generator = "openai/gpt-image-2" +source-sha256 = "6a1e81834e70f860362303d9b659aa5287a9388524413e99380e49380e2f15d7" + +[sources.ffa8705a-ac74-4348-8208-e139a9d95126] +collection = "generated" +page = "https://grida.co/library/o/ffa8705a-ac74-4348-8208-e139a9d95126" +download = "https://mozagqllybnbytfcmvdh-all.supabase.co/storage/v1/object/public/library/generated/4b4a83e4-fa95-4bbb-97ca-f6fee533186c.png?download=" +license = "CC0-1.0" +generator = "gpt-image-1" +source-sha256 = "68e750b6874a567c3bd2bdcde964239089876e2165486a40fe3ec127a95b96bb" diff --git a/crates/spock-cli/tests/provider_fixtures/play-assets.ts b/crates/spock-cli/tests/provider_fixtures/play-assets.ts new file mode 100644 index 0000000..0e2c70b --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/play-assets.ts @@ -0,0 +1,39 @@ +const LOCAL_PLAY_ASSETS: Readonly> = { + "avatar-mira": "avatar-mira.webp", + "avatar-lena": "avatar-lena.webp", + "avatar-marco": "avatar-marco.webp", + "avatar-nils": "avatar-nils.webp", + "avatar-priya": "avatar-priya.webp", + "avatar-ayla": "avatar-ayla.webp", + "avatar-june": "avatar-june.webp", + "avatar-theo": "avatar-theo.webp", + "avatar-kenji": "avatar-kenji.webp", + "media-lena-glaze": "media-lena-glaze.webp", + "media-marco-baja-1": "media-marco-baja-1.webp", + "media-marco-baja-2": "media-marco-baja-2.webp", + "media-marco-baja-3": "media-marco-baja-3.webp", + "media-nils-aurora-poster": "media-nils-aurora-poster.webp", + "media-priya-starter": "media-priya-starter.webp", + "media-ayla-ferry": "media-ayla-ferry.webp", + "media-june-lookbook": "media-june-lookbook.webp", + "media-theo-court": "media-theo-court.webp", + "media-kenji-copper": "media-kenji-copper.webp", + "thumb-lena-1": "thumb-lena-1.webp", + "thumb-lena-2": "thumb-lena-2.webp", + "thumb-lena-3": "thumb-lena-3.webp", + "thumb-lena-4": "thumb-lena-4.webp", + "thumb-lena-5": "thumb-lena-5.webp", + "thumb-lena-6": "thumb-lena-6.webp", + "thumb-lena-7": "thumb-lena-7.webp", + "thumb-lena-8": "thumb-lena-8.webp", + "thumb-lena-9": "thumb-lena-9.webp", + "thumb-mira-1": "thumb-mira-1.webp", + "thumb-mira-2": "thumb-mira-2.webp", + "thumb-mira-3": "thumb-mira-3.webp", + "thumb-mira-4": "thumb-mira-4.webp", + "thumb-mira-5": "thumb-mira-5.webp", + "thumb-mira-6": "thumb-mira-6.webp", + "video-nils-aurora": "media-nils-aurora.mp4", + "video-theo-court": "media-theo-court.mp4", + "video-mira-ferry": "media-mira-ferry.mp4", +}; diff --git a/crates/spock-cli/tests/provider_fixtures/snapshot-query.graphql b/crates/spock-cli/tests/provider_fixtures/snapshot-query.graphql new file mode 100644 index 0000000..22881b0 --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/snapshot-query.graphql @@ -0,0 +1,69 @@ + + query UhuraSnapshot { + users: user(limit: 200) { + id + username + display_name + avatar { id } + avatar_alt + bio + } + stories: story(limit: 200) { + id + author { id } + position + media_file { id } + media_alt + caption + published_at + } + storyViews: story_view(limit: 200) { + viewer { id } + story { id } + at + } + posts: post(limit: 200) { + id + author { id } + caption + published_at + show_in_feed + media_kind + media_file { id } + video_file { id } + media_alt + } + slides: carousel_slide(limit: 200) { + id + post { id } + position + file { id } + alt + } + comments: comment(limit: 200) { + id + post { id } + author { id } + body + created_at + } + likes: like(limit: 200) { + user { id } + post { id } + at + } + saves: save(limit: 200) { + user { id } + post { id } + at + } + follows: follow(limit: 200) { + follower { id } + followed { id } + at + } + postTags: post_tag(limit: 200) { + post { id } + person { id } + } + } diff --git a/crates/spock-cli/tests/provider_fixtures/spock-provider.ts b/crates/spock-cli/tests/provider_fixtures/spock-provider.ts new file mode 100644 index 0000000..55d3c35 --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/spock-provider.ts @@ -0,0 +1,2089 @@ +// Instagram's app-local adapter provider. Uhura owns the deterministic +// machine; this module observes Spock authority and performs requested +// mutations at the two explicitly admitted application ports. + +const PAGE_SIZE = 4; +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + +// Evidence fixtures use stable logical names while Play serves exact captured +// files. Live Spock storage ids deliberately fall through to signed URLs. +const LOCAL_PLAY_ASSETS: Readonly> = { + "avatar-mira": "avatar-mira.webp", + "avatar-lena": "avatar-lena.webp", + "avatar-marco": "avatar-marco.webp", + "avatar-nils": "avatar-nils.webp", + "avatar-priya": "avatar-priya.webp", + "avatar-ayla": "avatar-ayla.webp", + "avatar-june": "avatar-june.webp", + "avatar-theo": "avatar-theo.webp", + "avatar-kenji": "avatar-kenji.webp", + "media-lena-glaze": "media-lena-glaze.webp", + "media-marco-baja-1": "media-marco-baja-1.webp", + "media-marco-baja-2": "media-marco-baja-2.webp", + "media-marco-baja-3": "media-marco-baja-3.webp", + "media-nils-aurora-poster": "media-nils-aurora-poster.webp", + "media-priya-starter": "media-priya-starter.webp", + "media-ayla-ferry": "media-ayla-ferry.webp", + "media-june-lookbook": "media-june-lookbook.webp", + "media-theo-court": "media-theo-court.webp", + "media-kenji-copper": "media-kenji-copper.webp", + "thumb-lena-1": "thumb-lena-1.webp", + "thumb-lena-2": "thumb-lena-2.webp", + "thumb-lena-3": "thumb-lena-3.webp", + "thumb-lena-4": "thumb-lena-4.webp", + "thumb-lena-5": "thumb-lena-5.webp", + "thumb-lena-6": "thumb-lena-6.webp", + "thumb-lena-7": "thumb-lena-7.webp", + "thumb-lena-8": "thumb-lena-8.webp", + "thumb-lena-9": "thumb-lena-9.webp", + "thumb-mira-1": "thumb-mira-1.webp", + "thumb-mira-2": "thumb-mira-2.webp", + "thumb-mira-3": "thumb-mira-3.webp", + "thumb-mira-4": "thumb-mira-4.webp", + "thumb-mira-5": "thumb-mira-5.webp", + "thumb-mira-6": "thumb-mira-6.webp", + "video-nils-aurora": "media-nils-aurora.mp4", + "video-theo-court": "media-theo-court.mp4", + "video-mira-ferry": "media-mira-ferry.mp4", +}; + +// The current Spock authority caps one collection read at 200 rows. This demo fits inside +// that ceiling per table; snapshot-consistent pagination is deferred dogfood +// rather than pretending a clamped response is complete. +const SNAPSHOT_QUERY = ` + query UhuraSnapshot { + users: user(limit: 200) { + id + username + display_name + avatar { id } + avatar_alt + bio + } + stories: story(limit: 200) { + id + author { id } + position + media_file { id } + media_alt + caption + published_at + } + storyViews: story_view(limit: 200) { + viewer { id } + story { id } + at + } + posts: post(limit: 200) { + id + author { id } + caption + published_at + show_in_feed + media_kind + media_file { id } + video_file { id } + media_alt + } + slides: carousel_slide(limit: 200) { + id + post { id } + position + file { id } + alt + } + comments: comment(limit: 200) { + id + post { id } + author { id } + body + created_at + } + likes: like(limit: 200) { + user { id } + post { id } + at + } + saves: save(limit: 200) { + user { id } + post { id } + at + } + follows: follow(limit: 200) { + follower { id } + followed { id } + at + } + postTags: post_tag(limit: 200) { + post { id } + person { id } + } + } +`; + +const COMMAND_REFUSALS: Readonly> = { + "feed/like-post": ["not-authorized", "not-found"], + "feed/unlike-post": ["not-authorized"], + "feed/save-post": ["not-authorized", "not-found"], + "feed/unsave-post": ["not-authorized"], + "feed/mark-story-seen": ["not-authorized", "not-found"], + "comments/add-comment": ["not-authorized", "comment-body-invalid", "not-found"], + "profile/follow-user": [ + "not-authorized", + "not-found", + "cannot-follow-self", + ], + "profile/unfollow-user": ["not-authorized"], + "create/publish-image": [ + "not-authorized", + "image-not-ready", + "unsupported-media-type", + ], +}; + +export interface SpockProviderConfig { + /** Standalone fallback for the full Spock `/graphql/v1` endpoint. */ + graphql_url: string; + /** Standalone fallback for the Spock `/rest/v1/rpc` prefix. */ + rpc_url: string; + /** Standalone fallback for the Spock `/storage/v1` prefix. */ + storage_url: string; + /** Seeded user UUID or unique username. */ + actor: string; +} + +export interface ProviderHost { + /** Aborts when the Play route that owns this provider is retired. */ + readonly signal: AbortSignal; + /** + * Browser capability supplied by the play shell. The selected File remains + * entirely outside Uhura Core and its wire envelopes. + */ + pickFile(options: { accept: string }): Promise; +} + +interface PortRequirement { + readonly port: string; + readonly adapter: "app.provider"; + readonly contractHash: string; + readonly contractInstanceHash: string; +} + +interface PortAdapterContext { + readonly signal: AbortSignal; + deliver(value: WireValue): void; +} + +interface AdapterProviderHost extends ProviderHost { + port(name: string): PortRequirement; +} + +interface PortAdapter extends PortRequirement { + start?(context: PortAdapterContext): void | Promise; + accept(command: WireValue, context: PortAdapterContext): void | Promise; + dispose?(): void; +} + +interface WireValue { + readonly $: string; + readonly [field: string]: unknown; +} + +export interface RemoteSystemInfo { + actor: string | null; + actors: Array<{ id: string; username: string; label: string }>; +} + +interface SpockBackend { + dispose(): void; + load(): Promise; + execute(operation: BackendOperation): Promise; + authorityValue(): WireValue; + resolveAsset(asset: string): Promise; + systemInfo(): RemoteSystemInfo; +} + +const INSTAGRAM_MODULE = "app.instagram@1"; +const INSTAGRAM_MACHINE = `${INSTAGRAM_MODULE}::Instagram`; +const USER_ID_TYPE = `${INSTAGRAM_MODULE}::UserId`; +const POST_ID_TYPE = `${INSTAGRAM_MODULE}::PostId`; +const STORY_ID_TYPE = `${INSTAGRAM_MODULE}::StoryId`; +const REQUEST_ID_TYPE = `${INSTAGRAM_MODULE}::RequestId`; +const AUTHORITY_TYPE = `${INSTAGRAM_MODULE}::Authority`; +const MEDIA_TYPE = `${INSTAGRAM_MODULE}::Media`; +const MUTATION_TYPE = `${INSTAGRAM_MODULE}::Mutation`; +const SETTLEMENT_TYPE = `${INSTAGRAM_MODULE}::Settlement`; +const AUTHORITY_RECEIVE_TYPE = + `${INSTAGRAM_MACHINE}::port.authority.Receive`; +const MUTATIONS_SEND_TYPE = + `${INSTAGRAM_MACHINE}::port.mutations.Send`; +const MUTATIONS_RECEIVE_TYPE = + `${INSTAGRAM_MACHINE}::port.mutations.Receive`; + +const wireText = (value: string): WireValue => ({ $: "Text", value }); +const wireBool = (value: boolean): WireValue => ({ $: "bool", value }); +const wireNat = (value: number): WireValue => ({ + $: "Nat", + value: String(value), +}); +const wireKey = ( + type: string, + value: WireValue, +): WireValue => ({ $: "key", type, value }); +const wireRecord = ( + fields: ReadonlyArray, +): WireValue => ({ + $: "record", + fields: fields.map(([name, value]) => ({ name, value })), +}); +const wireVariant = ( + type: string, + caseName: string, + fields: ReadonlyArray = [], +): WireValue => ({ + $: "variant", + type, + case: caseName, + fields: fields.map(([name, value]) => ({ name, value })), +}); +const wireSeq = (items: readonly WireValue[]): WireValue => ({ + $: "seq", + items, +}); +const wireMap = ( + entries: ReadonlyArray, +): WireValue => ({ $: "map", entries }); +const textEncoder = new TextEncoder(); +const lengthPrefix = (value: number): number[] => { + const bytes: number[] = []; + do { + let byte = value % 128; + value = Math.floor(value / 128); + if (value !== 0) byte += 128; + bytes.push(byte); + } while (value !== 0); + return bytes; +}; +const canonicalTextKeyBytes = (value: string): Uint8Array => { + const body = textEncoder.encode(value); + return Uint8Array.from([...lengthPrefix(body.length), ...body]); +}; +const compareBytes = (left: Uint8Array, right: Uint8Array): number => { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const order = (left[index] ?? 0) - (right[index] ?? 0); + if (order !== 0) return order; + } + return left.length - right.length; +}; +/** + * Every map currently emitted by this adapter has a nominal Text-backed key. + * Uhura orders a map by the complete canonical key bytes. The nominal type is + * constant within one map, so its shared prefix cancels and the order reduces + * exactly to the length-framed UTF-8 Text body below. + */ +const wireTextKeyMap = ( + type: string, + entries: ReadonlyArray, +): WireValue => { + const ordered = entries + .map(([key, value]) => ({ + key, + value, + canonical: canonicalTextKeyBytes(key), + })) + .sort((left, right) => compareBytes(left.canonical, right.canonical)); + for (let index = 1; index < ordered.length; index += 1) { + if ( + compareBytes( + ordered[index - 1]?.canonical ?? new Uint8Array(), + ordered[index]?.canonical ?? new Uint8Array(), + ) === 0 + ) { + throw new Error(`duplicate canonical map key \`${ordered[index]?.key}\``); + } + } + return wireMap(ordered.map(({ key, value }) => [ + wireKey(type, wireText(key)), + value, + ])); +}; +const wireOption = ( + type: string, + value: WireValue | null, +): WireValue => wireVariant( + `Option<${type}>`, + value === null ? "none" : "some", + value === null ? [] : [["value", value]], +); + +/** + * A picker result is observed immediately so a rejection cannot become + * unhandled while an earlier provider command finishes. + */ +type PickedFile = Promise<{ file: File | null } | { error: unknown }>; + +// A retired backend instance can have a mutation already accepted by Spock. +// A replacement waits for that work before reading its authority snapshot, so a route +// remount cannot strand a just-accepted mutation behind stale boot data. +let authorityTail: Promise = Promise.resolve(); + +const AUTHORITY_OPERATIONS = new Set([ + "set_like", + "set_save", + "add_comment", + "mark_story", + "set_follow", + "publish_image_request", +]); + +const AUTHORITY_REQUEST_TIMEOUT_MS = 15_000; +const HOST_ENVIRONMENT_TIMEOUT_MS = 2_000; +const HOST_ENVIRONMENT_PATH = "/~project/environment"; +const HOST_ENVIRONMENT_PROTOCOL = "spock-host-environment/1"; + +interface AuthorityEndpoints { + graphqlUrl: string | null; + rpcUrl: string; + storageUrl: string; + whoamiUrl: string; +} + +function exactObject( + value: unknown, + keys: readonly string[], +): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const actual = Object.keys(value); + return ( + actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function authorityPath(value: unknown): string | null { + if ( + typeof value !== "string" || + !value.startsWith("/") || + value.startsWith("//") || + value === "/" || + value.endsWith("/") + ) { + return null; + } + try { + const parsed = new URL(value, "https://spock.invalid/"); + if ( + parsed.origin !== "https://spock.invalid" || + parsed.pathname !== value || + parsed.search.length > 0 || + parsed.hash.length > 0 + ) { + return null; + } + } catch { + return null; + } + return value; +} + +function integratedAuthority(value: unknown): AuthorityEndpoints | null { + if ( + !exactObject(value, [ + "protocol", + "mode", + "project_generation_id", + "backend_generation_id", + "authority", + ]) || + value.protocol !== HOST_ENVIRONMENT_PROTOCOL || + (value.mode !== "start" && value.mode !== "dev") || + !Number.isSafeInteger(value.project_generation_id) || + (value.project_generation_id as number) < 1 || + !Number.isSafeInteger(value.backend_generation_id) || + (value.backend_generation_id as number) < 1 || + !exactObject(value.authority, [ + "graphql_path", + "rpc_path", + "storage_path", + ]) + ) { + return null; + } + + const graphqlPath = value.authority.graphql_path; + // `null` is an explicit capability absence in the integrated environment, + // not invalid metadata and never a reason to contact the standalone host. + const graphqlUrl = graphqlPath === null ? null : authorityPath(graphqlPath); + const rpcUrl = authorityPath(value.authority.rpc_path); + const storageUrl = authorityPath(value.authority.storage_path); + if ( + (graphqlPath !== null && graphqlUrl === null) || + rpcUrl === null || + storageUrl === null + ) { + return null; + } + + return { + graphqlUrl, + rpcUrl, + storageUrl, + whoamiUrl: "/~whoami", + }; +} + +function resolveFromEndpoint(reference: string, endpoint: string): string { + try { + return new URL(reference, endpoint).toString(); + } catch { + const sameOrigin = "https://spock.invalid"; + const resolved = new URL(reference, `${sameOrigin}${endpoint}`); + return resolved.origin === sameOrigin + ? `${resolved.pathname}${resolved.search}${resolved.hash}` + : resolved.toString(); + } +} + +function enqueueAuthorityWork(work: () => Promise): Promise { + const queued = authorityTail.then(work, work); + authorityTail = queued.then( + () => {}, + () => {}, + ); + return queued; +} + +type GraphRef = { id: string }; + +interface UserRow { + id: string; + username: string; + display_name: string; + avatar: GraphRef; + avatar_alt: string; + bio: string | null; +} + +interface GraphStory { + id: string; + author: GraphRef; + position: number; + media_file: GraphRef; + media_alt: string; + caption: string | null; + published_at: string; +} + +interface StoryRow { + id: string; + author: string; + position: number; + media_file: string; + media_alt: string; + caption: string | null; + published_at: string; +} + +interface GraphStoryView { + viewer: GraphRef; + story: GraphRef; + at: string; +} + +type MediaKind = "image" | "carousel" | "video"; + +interface GraphPost { + id: string; + author: GraphRef; + caption: string; + published_at: string; + show_in_feed: boolean; + media_kind: MediaKind; + media_file: GraphRef | null; + video_file: GraphRef | null; + media_alt: string | null; +} + +interface PostRow { + id: string; + author: string; + caption: string; + published_at: string; + show_in_feed: boolean; + media_kind: MediaKind; + media_file: string | null; + video_file: string | null; + media_alt: string | null; +} + +interface GraphSlide { + id: string; + post: GraphRef; + position: number; + file: GraphRef; + alt: string; +} + +interface SlideRow { + id: string; + post: string; + position: number; + file: string; + alt: string; +} + +interface GraphComment { + id: string; + post: GraphRef; + author: GraphRef; + body: string; + created_at: string; +} + +interface CommentRow { + id: string; + post: string; + author: string; + body: string; + created_at: string; +} + +interface GraphLike { + user: GraphRef; + post: GraphRef; + at: string; +} + +interface GraphSave extends GraphLike {} + +interface GraphFollow { + follower: GraphRef; + followed: GraphRef; + at: string; +} + +interface GraphPostTag { + post: GraphRef; + person: GraphRef; +} + +interface SnapshotData { + users: UserRow[]; + stories: GraphStory[]; + storyViews: GraphStoryView[]; + posts: GraphPost[]; + slides: GraphSlide[]; + comments: GraphComment[]; + likes: GraphLike[]; + saves: GraphSave[]; + follows: GraphFollow[]; + postTags: GraphPostTag[]; +} + +interface GraphQlError { + message: string; + extensions?: Record; +} + +interface GraphQlEnvelope { + data?: SnapshotData; + errors?: GraphQlError[]; +} + +interface WhoAmI { + actor: unknown; + anonymous: boolean; + known: boolean; +} + +interface SpockError { + code?: string; + kind?: string; + table?: string | null; + fields?: string[]; + message?: string; +} + +type RpcReply = + | { ok: true; result: unknown } + | { ok: false; error: SpockError }; + +type BackendOperation = + | { kind: "set_like"; post: string; liked: boolean } + | { kind: "set_save"; post: string; saved: boolean } + | { kind: "load_more" } + | { kind: "reload_feed" } + | { kind: "set_follow"; user: string; following: boolean } + | { kind: "add_comment"; post: string; body: string } + | { kind: "search_people"; query: string } + | { kind: "choose_image_request" } + | { + kind: "publish_image_request"; + object: string; + caption: string; + alt: string; + } + | { kind: "mark_story"; story: string }; + +type BackendSettlement = + | { kind: "accepted" } + | { kind: "refused"; reason: string } + | { + kind: "image_ready"; + object: string; + preview: string; + name: string; + }; + +interface Database { + users: Map; + stories: StoryRow[]; + storyViews: Set; + posts: PostRow[]; + slidesByPost: Map; + commentsByPost: Map; + likeCounts: Map; + liked: Set; + saved: Set; + follows: Set; + followersByUser: Map; + followingByUser: Map; + taggedPostsByUser: Map; +} + +/** + * Encode a wire value, rejecting the one JavaScript value JSON cannot encode. + * @param {unknown} value + * @returns {string} + */ +function encode(value: unknown): string { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("provider tried to encode `undefined`"); + return encoded; +} + +/** + * @param {unknown} error + * @returns {string} + */ +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * @template T + * @param {T[]} rows + * @param {(row: T) => string} keyOf + * @returns {Map} + */ +function groupBy(rows: T[], keyOf: (row: T) => string): Map { + const grouped = new Map(); + for (const row of rows) { + const key = keyOf(row); + const bucket = grouped.get(key) ?? []; + bucket.push(row); + grouped.set(key, bucket); + } + return grouped; +} + +/** + * Format an authority timestamp for the small social-demo UI. No age label is + * stored in Spock: it is recomputed whenever a fresh snapshot is projected. + * @param {string} timestamp + * @returns {string} + */ +function ageLabel(timestamp: string): string { + const instant = Date.parse(timestamp); + if (!Number.isFinite(instant)) { + throw new Error(`invalid authority timestamp \`${timestamp}\``); + } + const elapsed = Math.max(0, Date.now() - instant); + const minute = 60_000; + const hour = 60 * minute; + const day = 24 * hour; + if (elapsed < minute) return "now"; + if (elapsed < hour) return `${Math.floor(elapsed / minute)}m`; + if (elapsed < day) return `${Math.floor(elapsed / hour)}h`; + if (elapsed < 7 * day) return `${Math.floor(elapsed / day)}d`; + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(new Date(instant)); +} + +/** + * @param {string} left + * @param {string} right + * @returns {number} + */ +function newestFirst(left: string, right: string): number { + return Date.parse(right) - Date.parse(left); +} + +/** + * @param {string} left + * @param {string} right + * @returns {number} + */ +function oldestFirst(left: string, right: string): number { + return Date.parse(left) - Date.parse(right); +} + +/** + * @param {string} left + * @param {string} right + * @returns {string} + */ +function edgeKey(left: string, right: string): string { + return `${left}|${right}`; +} + +/** + * Spock errors use snake_case; Uhura refusal names use kebab-case. + * @param {string} code + * @returns {string} + */ +function toRefusalName(code: string): string { + return code.replaceAll("_", "-"); +} + +/** + * Create the app-local Spock authority bridge used by the admitted Uhura + * ports. It exposes domain operations and typed authority values directly; + * there is no second provider protocol or projection/outcome envelope. + */ +function createSpockBackend( + { graphql_url, rpc_url, storage_url, actor }: SpockProviderConfig, + host: ProviderHost, +): SpockBackend { + const graphqlUrl = graphql_url.replace(/\/+$/, ""); + const rpcUrl = rpc_url.replace(/\/+$/, ""); + const storageUrl = storage_url.replace(/\/+$/, ""); + if (graphqlUrl.length === 0) { + throw new Error("Spock provider needs `graphql_url`"); + } + if (rpcUrl.length === 0) throw new Error("Spock provider needs `rpc_url`"); + if (storageUrl.length === 0) { + throw new Error("Spock provider needs `storage_url`"); + } + const configuredAuthority: AuthorityEndpoints = { + graphqlUrl, + rpcUrl, + storageUrl, + whoamiUrl: new URL("/~whoami", graphqlUrl).toString(), + }; + + const signedAssets = new Map(); + const signingAssets = new Map>(); + const uploadedFileNames = new Map(); + let operationTail: Promise = Promise.resolve(); + const cancellable = new AbortController(); + let disposed = host.signal.aborted; + let authorityResolution: Promise | undefined; + + function dispose(): void { + if (disposed) return; + disposed = true; + host.signal.removeEventListener("abort", dispose); + cancellable.abort(); + signedAssets.clear(); + signingAssets.clear(); + uploadedFileNames.clear(); + } + + if (disposed) cancellable.abort(); + else host.signal.addEventListener("abort", dispose, { once: true }); + + function assertLive(): void { + if (!disposed) return; + throw new DOMException("Uhura Play provider was disposed", "AbortError"); + } + + function authorityEndpoints(): Promise { + authorityResolution ??= (async () => { + // Discovery is opportunistic for standalone Uhura sessions. Bound it so + // a same-origin route that accepts but never answers cannot stall boot. + const discovery = new AbortController(); + const abortDiscovery = (): void => discovery.abort(); + if (cancellable.signal.aborted) abortDiscovery(); + else { + cancellable.signal.addEventListener("abort", abortDiscovery, { + once: true, + }); + } + const timeout = setTimeout(abortDiscovery, HOST_ENVIRONMENT_TIMEOUT_MS); + try { + const response = await fetch(HOST_ENVIRONMENT_PATH, { + method: "GET", + headers: { accept: "application/json" }, + signal: discovery.signal, + }); + assertLive(); + if (!response.ok) return configuredAuthority; + const body = await response.text(); + assertLive(); + const environment = integratedAuthority(JSON.parse(body)); + return environment ?? configuredAuthority; + } catch (error) { + if (disposed || cancellable.signal.aborted) throw error; + return configuredAuthority; + } finally { + clearTimeout(timeout); + cancellable.signal.removeEventListener("abort", abortDiscovery); + } + })(); + return authorityResolution; + } + + /** + * @returns {Promise} + */ + async function fetchSnapshot(): Promise { + const { graphqlUrl } = await authorityEndpoints(); + if (graphqlUrl === null) { + throw new Error( + "integrated Spock host does not advertise a GraphQL capability", + ); + } + const response = await fetch(graphqlUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: encode({ query: SNAPSHOT_QUERY }), + signal: cancellable.signal, + }); + const body = await response.text(); + if (!response.ok) { + throw new Error(`POST /graphql/v1: ${response.status} ${body}`); + } + + const envelope = JSON.parse(body) as GraphQlEnvelope; + if (envelope.errors && envelope.errors.length > 0) { + const details = envelope.errors.map((error) => error.message).join("; "); + throw new Error(`GraphQL snapshot failed: ${details}`); + } + if (!envelope.data) throw new Error("GraphQL snapshot returned no data"); + return envelope.data; + } + + let viewerRow: UserRow | null = null; + + /** + * The RPC identity is always the resolved auth-table UUID, even when the + * provider was configured with a username. + * @returns {string} + */ + function viewerId(): string { + if (!viewerRow) throw new Error("Spock provider has not assembled boot"); + return viewerRow.id; + } + + /** + * Prove that Spock resolves the normalized UUID to the same auth-table row. + * This catches a wrong server, stale seed, or malformed actor before the + * session accepts its boot projection. + * @returns {Promise} + */ + async function verifyViewer(): Promise { + const expected = viewerId(); + const { whoamiUrl } = await authorityEndpoints(); + const response = await fetch(whoamiUrl, { + headers: { "x-spock-actor": expected }, + signal: cancellable.signal, + }); + const body = await response.text(); + if (!response.ok) { + throw new Error(`GET /~whoami: ${response.status} ${body}`); + } + const identity = JSON.parse(body) as WhoAmI; + if (identity.anonymous || !identity.known || identity.actor !== expected) { + throw new Error( + `Spock did not recognize resolved actor \`${expected}\`: ${body}`, + ); + } + } + + /** + * @param {string} fn + * @param {Record} payload + * @returns {Promise} + */ + async function rpc( + fn: string, + payload: Record, + ): Promise { + const { rpcUrl } = await authorityEndpoints(); + const timeout = new AbortController(); + const timeoutId = setTimeout( + () => timeout.abort(), + AUTHORITY_REQUEST_TIMEOUT_MS, + ); + let response: Response; + try { + // Once sent, a domain mutation may already be accepted by Spock. Do not + // abort it merely because its route retired; the module-level authority + // barrier makes the replacement backend wait for settlement. The finite timeout + // prevents a broken connection from blocking every future boot forever. + response = await fetch(`${rpcUrl}/${fn}`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-spock-actor": viewerId(), + }, + body: encode(payload), + signal: timeout.signal, + }); + } finally { + clearTimeout(timeoutId); + } + const body = await response.text(); + if (response.ok) { + return { ok: true, result: body ? JSON.parse(body) : null }; + } + + let error: SpockError = { message: body }; + try { + const envelope = JSON.parse(body) as { error?: SpockError }; + error = envelope.error ?? error; + } catch { + // Preserve a non-JSON body as the provider-facing reason. + } + return { ok: false, error }; + } + + /** + * Mint a short-lived signed download URL only when the shell is about to + * render an asset. The provider owns expiry because the asset value carried + * through Uhura remains the stable storage-object id. + * @param {string} asset + * @returns {Promise} + */ + async function resolveAsset(asset: string): Promise { + if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(asset)) return asset; + const local = LOCAL_PLAY_ASSETS[asset]; + if (local) { + return `/api/play/assets/${encodeURIComponent(local)}`; + } + assertLive(); + const cached = signedAssets.get(asset); + if (cached && Date.now() < cached.refreshAt) return cached.url; + const current = signingAssets.get(asset); + if (current) return current; + + const signing = (async () => { + const { storageUrl } = await authorityEndpoints(); + const response = await fetch( + `${storageUrl}/object/sign/${encodeURIComponent(asset)}`, + { + method: "POST", + headers: { "x-spock-actor": viewerId() }, + signal: cancellable.signal, + }, + ); + const body = await response.text(); + if (!response.ok) { + throw new Error( + `POST /storage/v1/object/sign/${asset}: ${response.status} ${body}`, + ); + } + const envelope = JSON.parse(body) as { url?: string }; + if (typeof envelope.url !== "string") { + throw new Error("Spock storage signing returned no URL"); + } + const absolute = resolveFromEndpoint(envelope.url, storageUrl); + const expiry = Number( + new URL(absolute, "https://spock.invalid/").searchParams.get("exp"), + ); + const refreshAt = Number.isFinite(expiry) + ? Math.max(Date.now(), expiry * 1000 - 30_000) + : Date.now(); + signedAssets.set(asset, { url: absolute, refreshAt }); + return absolute; + })(); + signingAssets.set(asset, signing); + try { + return await signing; + } finally { + if (signingAssets.get(asset) === signing) signingAssets.delete(asset); + } + } + + /** + * Upload the browser-owned File directly to Spock's byte plane. Projections + * carry only the resulting id and serializable display metadata; commands + * never carry a File or its bytes. + * @param {File} file + * @returns {Promise} + */ + async function uploadFile(file: File): Promise { + const contentType = file.type.trim().toLowerCase(); + if (!SUPPORTED_IMAGE_TYPES.has(contentType)) { + throw new Error("Choose an image file (JPEG, PNG, or WebP)"); + } + + const { storageUrl } = await authorityEndpoints(); + const mintResponse = await fetch(`${storageUrl}/object/upload/sign`, { + method: "POST", + headers: { "x-spock-actor": viewerId() }, + signal: cancellable.signal, + }); + const mintBody = await mintResponse.text(); + if (!mintResponse.ok) { + throw new Error( + `POST /storage/v1/object/upload/sign: ${mintResponse.status} ${mintBody}`, + ); + } + const mint = JSON.parse(mintBody) as { id?: string; url?: string }; + if (typeof mint.id !== "string" || typeof mint.url !== "string") { + throw new Error("Spock storage upload signing returned no object id or URL"); + } + + const putUrl = resolveFromEndpoint(mint.url, storageUrl); + const putResponse = await fetch(putUrl, { + method: "PUT", + headers: { "content-type": contentType }, + body: file, + signal: cancellable.signal, + }); + const putBody = await putResponse.text(); + if (!putResponse.ok) { + throw new Error( + `PUT signed storage object: ${putResponse.status} ${putBody}`, + ); + } + signedAssets.delete(mint.id); + return mint.id; + } + + const db: Database = { + users: new Map(), + stories: [], + storyViews: new Set(), + posts: [], + slidesByPost: new Map(), + commentsByPost: new Map(), + likeCounts: new Map(), + liked: new Set(), + saved: new Set(), + follows: new Set(), + followersByUser: new Map(), + followingByUser: new Map(), + taggedPostsByUser: new Map(), + }; + let feedCount = PAGE_SIZE; + let searchQuery = ""; + + /** + * Refresh every raw table cache from one GraphQL response. Relationship + * objects are normalized back to their scalar foreign keys so the port + * assembly below stays independent of GraphQL's representation. + * @returns {Promise} + */ + async function loadAll(): Promise { + const data = await fetchSnapshot(); + + db.users = new Map(data.users.map((user) => [user.id, user])); + db.stories = data.stories + .map((story) => ({ + id: story.id, + author: story.author.id, + position: story.position, + media_file: story.media_file.id, + media_alt: story.media_alt, + caption: story.caption, + published_at: story.published_at, + })) + .sort((left, right) => { + const byTime = newestFirst(left.published_at, right.published_at); + return byTime || left.position - right.position || left.id.localeCompare(right.id); + }); + db.storyViews = new Set( + data.storyViews.map((view) => edgeKey(view.viewer.id, view.story.id)), + ); + db.posts = data.posts + .map((post) => ({ + id: post.id, + author: post.author.id, + caption: post.caption, + published_at: post.published_at, + show_in_feed: post.show_in_feed, + media_kind: post.media_kind, + media_file: post.media_file?.id ?? null, + video_file: post.video_file?.id ?? null, + media_alt: post.media_alt, + })) + .sort((left, right) => { + const byTime = newestFirst(left.published_at, right.published_at); + return byTime || left.id.localeCompare(right.id); + }); + + const slides: SlideRow[] = data.slides.map((slide) => ({ + id: slide.id, + post: slide.post.id, + position: slide.position, + file: slide.file.id, + alt: slide.alt, + })); + db.slidesByPost = groupBy(slides, (slide) => slide.post); + for (const rows of db.slidesByPost.values()) { + rows.sort((left, right) => left.position - right.position); + } + + const comments: CommentRow[] = data.comments.map((comment) => ({ + id: comment.id, + post: comment.post.id, + author: comment.author.id, + body: comment.body, + created_at: comment.created_at, + })); + db.commentsByPost = groupBy(comments, (comment) => comment.post); + for (const rows of db.commentsByPost.values()) { + rows.sort((left, right) => { + const byTime = oldestFirst(left.created_at, right.created_at); + return byTime || left.id.localeCompare(right.id); + }); + } + + db.likeCounts = new Map(); + for (const like of data.likes) { + const post = like.post.id; + db.likeCounts.set(post, (db.likeCounts.get(post) ?? 0) + 1); + } + + db.follows = new Set(); + db.followersByUser = new Map(); + db.followingByUser = new Map(); + for (const follow of data.follows) { + const follower = follow.follower.id; + const followed = follow.followed.id; + db.follows.add(edgeKey(follower, followed)); + const followers = db.followersByUser.get(followed) ?? []; + followers.push(follower); + db.followersByUser.set(followed, followers); + const following = db.followingByUser.get(follower) ?? []; + following.push(followed); + db.followingByUser.set(follower, following); + } + + db.taggedPostsByUser = new Map(); + for (const tag of data.postTags) { + const posts = db.taggedPostsByUser.get(tag.person.id) ?? []; + posts.push(tag.post.id); + db.taggedPostsByUser.set(tag.person.id, posts); + } + + const resolved = data.users.find( + (user) => user.id === actor || user.username === actor, + ); + // Keep the authority-owned user directory available even when a stale + // tab-local actor selection cannot resolve. `load` still refuses + // that identity, but the system chrome can offer a valid actor and recover + // by replacing the stored selection. + viewerRow = resolved ?? null; + db.liked = new Set( + data.likes + .filter((like) => like.user.id === resolved?.id) + .map((like) => like.post.id), + ); + db.saved = new Set( + data.saves + .filter((save) => save.user.id === resolved?.id) + .map((save) => save.post.id), + ); + } + + /** + * @param {string} id + * @returns {UserRow} + */ + function requireUser(id: string): UserRow { + const user = db.users.get(id); + if (!user) throw new Error(`Spock snapshot references missing user \`${id}\``); + return user; + } + + /** + * @param {PostRow} post + * @returns {{ id: string, src: string, alt: string }} + */ + function postThumb(post: PostRow): { id: string; src: string; alt: string } { + if (post.media_kind === "carousel") { + const first = (db.slidesByPost.get(post.id) ?? [])[0]; + if (!first) throw new Error(`carousel post \`${post.id}\` has no slides`); + return { id: post.id, src: first.file, alt: first.alt }; + } + if (post.media_file === null || post.media_alt === null) { + throw new Error(`post \`${post.id}\` has no thumbnail media`); + } + return { id: post.id, src: post.media_file, alt: post.media_alt }; + } + + /** + * Home is a relationship projection, not a global dump: the actor sees + * their own publications and publications by accounts they currently + * follow. Explore and reels deliberately use their own broader policies. + * @param {string} author + * @returns {boolean} + */ + function isHomeAuthor(author: string): boolean { + return author === viewerId() || db.follows.has(edgeKey(viewerId(), author)); + } + + /** @returns {PostRow[]} */ + function feedPosts(): PostRow[] { + return db.posts.filter((post) => post.show_in_feed && isHomeAuthor(post.author)); + } + + function userWire(id: string): WireValue { + const user = requireUser(id); + return wireRecord([ + ["id", wireKey(USER_ID_TYPE, wireText(user.id))], + ["username", wireText(user.username)], + ["display_name", wireText(user.display_name)], + [ + "avatar", + wireRecord([ + ["src", wireText(user.avatar.id)], + ["alt", wireText(user.avatar_alt)], + ]), + ], + ]); + } + + function imageWire(src: string, alt: string): WireValue { + return wireRecord([ + ["src", wireText(src)], + ["alt", wireText(alt)], + ]); + } + + function mediaWire(post: PostRow): WireValue { + if (post.media_kind === "carousel") { + const slides = db.slidesByPost.get(post.id) ?? []; + return wireVariant(MEDIA_TYPE, "Carousel", [[ + "images", + wireSeq(slides.map((slide) => imageWire(slide.file, slide.alt))), + ]]); + } + if (post.media_file === null || post.media_alt === null) { + throw new Error( + `post \`${post.id}\` has incomplete ${post.media_kind} media`, + ); + } + const poster = imageWire(post.media_file, post.media_alt); + if (post.media_kind === "video") { + if (post.video_file === null) { + throw new Error(`video post \`${post.id}\` has no playable video_file`); + } + return wireVariant(MEDIA_TYPE, "Video", [ + ["src", wireText(post.video_file)], + ["poster", poster], + ]); + } + return wireVariant(MEDIA_TYPE, "Image", [["image", poster]]); + } + + function postWire(post: PostRow): WireValue { + return wireRecord([ + ["id", wireKey(POST_ID_TYPE, wireText(post.id))], + ["author", userWire(post.author)], + ["caption", wireText(post.caption)], + ["media", mediaWire(post)], + ["like_count", wireNat(db.likeCounts.get(post.id) ?? 0)], + [ + "comment_count", + wireNat((db.commentsByPost.get(post.id) ?? []).length), + ], + ["viewer_liked", wireBool(db.liked.has(post.id))], + ["viewer_saved", wireBool(db.saved.has(post.id))], + ["posted_label", wireText(ageLabel(post.published_at))], + ]); + } + + function tileWire(post: PostRow): WireValue { + const thumb = postThumb(post); + return wireRecord([ + ["post", wireKey(POST_ID_TYPE, wireText(post.id))], + ["image", imageWire(thumb.src, thumb.alt)], + ]); + } + + function connectionWire(id: string): WireValue { + return wireRecord([ + ["user", userWire(id)], + ["follows_viewer", wireBool(db.follows.has(edgeKey(id, viewerId())))], + [ + "viewer_follows", + wireBool(db.follows.has(edgeKey(viewerId(), id))), + ], + ]); + } + + function connectionSequence(ids: readonly string[]): WireValue { + const unique = [...new Set(ids)]; + unique.sort((left, right) => + requireUser(left).username.localeCompare(requireUser(right).username) + ); + return wireSeq(unique.map(connectionWire)); + } + + function commentWire(comment: CommentRow): WireValue { + return wireRecord([ + ["id", wireText(comment.id)], + ["author", userWire(comment.author)], + ["body", wireText(comment.body)], + ["posted_label", wireText(ageLabel(comment.created_at))], + ]); + } + + function storyDetailWire(story: StoryRow): WireValue { + const sequence = db.stories + .filter((candidate) => candidate.author === story.author) + .sort( + (left, right) => + left.position - right.position || left.id.localeCompare(right.id), + ); + const index = sequence.findIndex((candidate) => candidate.id === story.id); + if (index < 0) { + throw new Error(`story sequence lost frame \`${story.id}\``); + } + const previous = index > 0 ? sequence[index - 1]?.id ?? null : null; + const next = index + 1 < sequence.length + ? sequence[index + 1]?.id ?? null + : null; + return wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(story.id))], + ["author", userWire(story.author)], + ["image", imageWire(story.media_file, story.media_alt)], + ["caption", wireText(story.caption ?? "")], + ["posted_label", wireText(ageLabel(story.published_at))], + [ + "viewed", + wireBool(db.storyViews.has(edgeKey(viewerId(), story.id))), + ], + [ + "previous", + wireOption( + STORY_ID_TYPE, + previous === null + ? null + : wireKey(STORY_ID_TYPE, wireText(previous)), + ), + ], + [ + "next", + wireOption( + STORY_ID_TYPE, + next === null ? null : wireKey(STORY_ID_TYPE, wireText(next)), + ), + ], + [ + "progress", + wireSeq(sequence.map((frame) => + wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(frame.id))], + ["current", wireBool(frame.id === story.id)], + [ + "viewed", + wireBool(db.storyViews.has(edgeKey(viewerId(), frame.id))), + ], + ]) + )), + ], + ]); + } + + function storyRingWires(): WireValue[] { + const grouped = groupBy( + db.stories.filter((story) => isHomeAuthor(story.author)), + (story) => story.author, + ); + const rings = [...grouped.entries()].map(([author, stories]) => { + stories.sort( + (left, right) => + left.position - right.position || left.id.localeCompare(right.id), + ); + const unseen = stories.filter( + (story) => !db.storyViews.has(edgeKey(viewerId(), story.id)), + ); + const self = author === viewerId(); + const selected = self ? stories[0] : unseen[0] ?? stories[0]; + if (!selected) throw new Error(`story author \`${author}\` has no frames`); + const newest = stories.reduce((latest, story) => + newestFirst(latest.published_at, story.published_at) <= 0 + ? latest + : story + ); + return { + author, + selected, + newest, + unseen: !self && unseen.length > 0, + self, + }; + }); + rings.sort((left, right) => + Number(right.self) - Number(left.self) + || newestFirst(left.newest.published_at, right.newest.published_at) + || requireUser(left.author).username.localeCompare( + requireUser(right.author).username, + ) + ); + return rings.map((ring) => + wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(ring.selected.id))], + ["user", userWire(ring.author)], + ["unseen", wireBool(ring.unseen)], + ["is_self", wireBool(ring.self)], + ]) + ); + } + + function profileWire(id: string): WireValue { + const user = requireUser(id); + const posts = db.posts.filter((post) => post.author === id); + const tagged = new Set(db.taggedPostsByUser.get(id) ?? []); + return wireRecord([ + ["user", userWire(id)], + ["bio", wireText(user.bio ?? "")], + ["post_count", wireNat(posts.length)], + [ + "follower_count", + wireNat((db.followersByUser.get(id) ?? []).length), + ], + [ + "following_count", + wireNat((db.followingByUser.get(id) ?? []).length), + ], + [ + "viewer_follows", + wireBool(db.follows.has(edgeKey(viewerId(), id))), + ], + ["posts", wireSeq(posts.map(tileWire))], + [ + "reels", + wireSeq(posts.filter((post) => post.media_kind === "video").map(tileWire)), + ], + [ + "tagged", + wireSeq(db.posts.filter((post) => tagged.has(post.id)).map(tileWire)), + ], + [ + "saved", + wireSeq( + id === viewerId() + ? db.posts.filter((post) => db.saved.has(post.id)).map(tileWire) + : [], + ), + ], + ]); + } + + function authorityValue(): WireValue { + const home = feedPosts(); + const visible = home.slice(0, feedCount); + const needle = searchQuery.trim().toLocaleLowerCase(); + const searchPeople = [...db.users.values()] + .filter((user) => user.id !== viewerId()) + .filter((user) => + needle.length === 0 + || user.username.toLocaleLowerCase().includes(needle) + || user.display_name.toLocaleLowerCase().includes(needle) + ) + .map((user) => user.id); + const users = [...db.users.keys()]; + return wireVariant(AUTHORITY_TYPE, "Ready", [[ + "data", + wireRecord([ + ["viewer", userWire(viewerId())], + [ + "posts", + wireTextKeyMap(POST_ID_TYPE, db.posts.map((post) => [ + post.id, + postWire(post), + ])), + ], + ["feed_posts", wireSeq(visible.map(postWire))], + ["feed_has_more", wireBool(feedCount < home.length)], + [ + "reels", + wireSeq( + db.posts.filter((post) => post.media_kind === "video").map(postWire), + ), + ], + ["stories", wireSeq(storyRingWires())], + [ + "story_details", + wireTextKeyMap(STORY_ID_TYPE, db.stories.map((story) => [ + story.id, + storyDetailWire(story), + ])), + ], + [ + "profiles", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + profileWire(id), + ])), + ], + [ + "followers", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + connectionSequence(db.followersByUser.get(id) ?? []), + ])), + ], + [ + "following", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + connectionSequence(db.followingByUser.get(id) ?? []), + ])), + ], + [ + "comments", + wireTextKeyMap(POST_ID_TYPE, db.posts.map((post) => [ + post.id, + wireSeq((db.commentsByPost.get(post.id) ?? []).map(commentWire)), + ])), + ], + ["search_people", connectionSequence(searchPeople)], + ["explore_tiles", wireSeq(db.posts.map(tileWire))], + ]), + ]]); + } + + /** + * The provider cannot inspect pixels, so an omitted author description gets + * provenance-only text rather than invented visual content. + * @param {string} image + * @returns {string} + */ + function fallbackUploadAlt(image: string): string { + const author = requireUser(viewerId()); + const fileName = uploadedFileNames.get(image)?.trim(); + return fileName + ? `Uploaded image “${fileName}” by ${author.display_name}` + : `Image uploaded by ${author.display_name}`; + } + + function refusal( + route: string, + error: SpockError, + ): BackendSettlement { + const reason = toRefusalName(error.code ?? ""); + if ((COMMAND_REFUSALS[route] ?? []).includes(reason)) { + return { kind: "refused", reason }; + } + return { + kind: "refused", + reason: error.message ?? error.code ?? "provider-error", + }; + } + + async function handle( + operation: BackendOperation, + pickedFile: PickedFile | undefined, + ): Promise { + try { + switch (operation.kind) { + case "set_like": { + const route = operation.liked + ? "feed/like-post" + : "feed/unlike-post"; + const reply = await rpc( + operation.liked ? "like_post" : "unlike_post", + { post: operation.post }, + ); + if (reply.ok === false) { + return refusal(route, reply.error); + } + await loadAll(); + return { kind: "accepted" }; + } + case "set_save": { + const route = operation.saved + ? "feed/save-post" + : "feed/unsave-post"; + const reply = await rpc( + operation.saved ? "save_post" : "unsave_post", + { post: operation.post }, + ); + if (reply.ok === false) { + return refusal(route, reply.error); + } + await loadAll(); + return { kind: "accepted" }; + } + case "add_comment": { + const route = "comments/add-comment"; + const reply = await rpc("add_comment", { + post: operation.post, + body: operation.body, + }); + if (reply.ok === false) { + return refusal(route, reply.error); + } + await loadAll(); + return { kind: "accepted" }; + } + case "load_more": { + await loadAll(); + feedCount = Math.min(feedCount + PAGE_SIZE, feedPosts().length); + return { kind: "accepted" }; + } + case "reload_feed": { + feedCount = PAGE_SIZE; + await loadAll(); + return { kind: "accepted" }; + } + case "mark_story": { + const route = "feed/mark-story-seen"; + const reply = await rpc("mark_story_viewed", { + story: operation.story, + }); + if (reply.ok === false) { + return refusal(route, reply.error); + } + await loadAll(); + return { kind: "accepted" }; + } + case "set_follow": { + const route = operation.following + ? "profile/follow-user" + : "profile/unfollow-user"; + const reply = await rpc( + operation.following ? "follow_user" : "unfollow_user", + { target: operation.user }, + ); + if (reply.ok === false) { + return refusal(route, reply.error); + } + await loadAll(); + return { kind: "accepted" }; + } + case "search_people": { + searchQuery = operation.query; + await loadAll(); + return { kind: "accepted" }; + } + case "choose_image_request": { + if (!pickedFile) { + throw new Error("this play host cannot choose local files"); + } + const picked = await pickedFile; + if ("error" in picked) throw picked.error; + if (picked.file === null) { + return { kind: "refused", reason: "selection-cancelled" }; + } + if (!SUPPORTED_IMAGE_TYPES.has(picked.file.type.trim().toLowerCase())) { + return { kind: "refused", reason: "unsupported-media-type" }; + } + const object = await uploadFile(picked.file); + uploadedFileNames.set(object, picked.file.name); + return { + kind: "image_ready", + object, + preview: object, + name: picked.file.name, + }; + } + case "publish_image_request": { + const route = "create/publish-image"; + const alt = operation.alt.trim().length > 0 + ? operation.alt + : fallbackUploadAlt(operation.object); + const reply = await rpc("create_image_post", { + image: operation.object, + caption: operation.caption, + alt, + }); + if (reply.ok === false) { + return refusal(route, reply.error); + } + if ( + typeof reply.result !== "object" || + reply.result === null || + !("id" in reply.result) || + typeof reply.result.id !== "string" + ) { + throw new Error("create_image_post returned no post id"); + } + await loadAll(); + uploadedFileNames.delete(operation.object); + return { kind: "accepted" }; + } + } + } catch (error) { + return { kind: "refused", reason: errorMessage(error) }; + } + } + + return { + dispose, + + systemInfo() { + return { + actor: viewerRow?.id ?? actor, + actors: [...db.users.values()] + .sort((left, right) => left.username.localeCompare(right.username)) + .map((user) => ({ + id: user.id, + username: user.username, + label: user.display_name, + })), + }; + }, + + async load() { + await authorityTail; + assertLive(); + await loadAll(); + assertLive(); + const viewer = viewerRow; + if (!viewer) throw new Error(`actor \`${actor}\` is not a seeded user`); + await verifyViewer(); + }, + + execute(operation: BackendOperation): Promise { + assertLive(); + let pickedFile: PickedFile | undefined; + if (operation.kind === "choose_image_request") { + try { + // This must happen in the click's synchronous call stack. Deferring + // it behind the operation queue would lose browser user activation. + pickedFile = host.pickFile({ accept: "image/jpeg,image/png,image/webp" }) + .then( + (file) => ({ file }), + (error) => ({ error }), + ); + } catch (error) { + pickedFile = Promise.resolve({ error }); + } + } + const work = operationTail.then(() => { + assertLive(); + const run = () => handle(operation, pickedFile); + return AUTHORITY_OPERATIONS.has(operation.kind) + ? enqueueAuthorityWork(run) + : run(); + }); + operationTail = work.then( + () => {}, + () => {}, + ); + return work; + }, + + authorityValue, + resolveAsset, + }; +} + +function wireObject(value: unknown, context: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${context} must be an object`); + } + return value as Record; +} + +function variantFields( + value: WireValue, + type: string, + caseName?: string, +): Map { + if (value.$ !== "variant" || value.type !== type) { + throw new TypeError(`expected Uhura variant ${type}`); + } + if (caseName !== undefined && value.case !== caseName) { + throw new TypeError(`expected Uhura variant ${type}.${caseName}`); + } + if (!Array.isArray(value.fields)) { + throw new TypeError(`Uhura variant ${type} has no fields`); + } + const fields = new Map(); + for (const raw of value.fields) { + const field = wireObject(raw, `${type} field`); + const name = field.name; + if (name !== null && typeof name !== "string") { + throw new TypeError(`${type} field name must be text or null`); + } + const child = wireObject(field.value, `${type} field value`) as WireValue; + if (fields.has(name)) throw new TypeError(`${type} repeats field ${String(name)}`); + fields.set(name, child); + } + return fields; +} + +function requiredField( + fields: ReadonlyMap, + name: string, +): WireValue { + const value = fields.get(name); + if (!value) throw new TypeError(`Uhura value has no field \`${name}\``); + return value; +} + +function keyText(value: WireValue, type: string): string { + if (value.$ !== "key" || value.type !== type) { + throw new TypeError(`expected Uhura key ${type}`); + } + const body = wireObject(value.value, `${type} body`); + if (body.$ !== "Text" || typeof body.value !== "string") { + throw new TypeError(`${type} must wrap Text`); + } + return body.value; +} + +function requestText(value: WireValue): string { + if (value.$ !== "key" || value.type !== REQUEST_ID_TYPE) { + throw new TypeError(`expected Uhura key ${REQUEST_ID_TYPE}`); + } + const body = wireObject(value.value, `${REQUEST_ID_TYPE} body`); + if ( + body.$ !== "PositiveInt" + || typeof body.value !== "string" + || !/^[1-9]\d*$/u.test(body.value) + ) { + throw new TypeError(`${REQUEST_ID_TYPE} must wrap PositiveInt`); + } + return body.value; +} + +function textValue(value: WireValue): string { + if (value.$ !== "Text" || typeof value.value !== "string") { + throw new TypeError("expected Uhura Text"); + } + return value.value; +} + +function boolValue(value: WireValue): boolean { + if (value.$ !== "bool" || typeof value.value !== "boolean") { + throw new TypeError("expected Uhura Bool"); + } + return value.value; +} + +interface AdaptedRequest { + readonly request: WireValue; + readonly operation: BackendOperation; +} + +function adaptRequest(command: WireValue): AdaptedRequest { + const requestFields = variantFields( + command, + MUTATIONS_SEND_TYPE, + "request", + ); + const request = requiredField(requestFields, "id"); + requestText(request); + const payload = requiredField(requestFields, "payload"); + const fields = variantFields(payload, MUTATION_TYPE); + const mutation = String(payload.case); + + switch (mutation) { + case "SetLike": + return { + request, + operation: { + kind: "set_like", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + liked: boolValue(requiredField(fields, "liked")), + }, + }; + case "SetSave": + return { + request, + operation: { + kind: "set_save", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + saved: boolValue(requiredField(fields, "saved")), + }, + }; + case "LoadMore": + return { request, operation: { kind: "load_more" } }; + case "ReloadFeed": + return { request, operation: { kind: "reload_feed" } }; + case "SetFollow": + return { + request, + operation: { + kind: "set_follow", + user: keyText(requiredField(fields, "user"), USER_ID_TYPE), + following: boolValue(requiredField(fields, "following")), + }, + }; + case "AddComment": + return { + request, + operation: { + kind: "add_comment", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + body: textValue(requiredField(fields, "body")), + }, + }; + case "SearchPeople": + return { + request, + operation: { + kind: "search_people", + query: textValue(requiredField(fields, "query")), + }, + }; + case "ChooseImage": + return { request, operation: { kind: "choose_image_request" } }; + case "PublishImage": + return { + request, + operation: { + kind: "publish_image_request", + object: textValue(requiredField(fields, "object")), + caption: textValue(requiredField(fields, "caption")), + alt: textValue(requiredField(fields, "alt")), + }, + }; + case "MarkStory": + return { + request, + operation: { + kind: "mark_story", + story: keyText(requiredField(fields, "story"), STORY_ID_TYPE), + }, + }; + default: + throw new TypeError(`unsupported Instagram mutation \`${mutation}\``); + } +} + +function observed(value: WireValue): WireValue { + return wireVariant( + AUTHORITY_RECEIVE_TYPE, + "authority.observed", + [["value", value]], + ); +} + +function refused(reason: string): WireValue { + return wireVariant(SETTLEMENT_TYPE, "Refused", [[ + "reason", + wireText(reason), + ]]); +} + +function settlementValue(result: BackendSettlement): WireValue { + switch (result.kind) { + case "accepted": + return wireVariant(SETTLEMENT_TYPE, "Accepted"); + case "refused": + return refused(result.reason); + case "image_ready": + return wireVariant(SETTLEMENT_TYPE, "ImageReady", [ + ["object", wireText(result.object)], + ["preview", wireText(result.preview)], + ["name", wireText(result.name)], + ]); + } +} + +function settled(request: WireValue, result: WireValue): WireValue { + return wireVariant( + MUTATIONS_RECEIVE_TYPE, + "mutations.settled", + [ + ["id", request], + ["result", result], + ], + ); +} + +function providerConfig( + config: Readonly>, +): SpockProviderConfig { + const value = (name: keyof SpockProviderConfig): string => { + const entry = config[name]; + if (typeof entry !== "string" || entry.trim().length === 0) { + throw new TypeError(`Instagram provider needs nonempty \`${name}\``); + } + return entry; + }; + return { + graphql_url: value("graphql_url"), + rpc_url: value("rpc_url"), + storage_url: value("storage_url"), + actor: value("actor"), + }; +} + +/** + * Current Uhura adapter entry point. Contract identities come from the + * admitted Play deployment; the app provider never calculates or hardcodes + * compiler-owned hashes. + */ +export function createUhuraAdapters( + config: Readonly>, + host: AdapterProviderHost, +): { + readonly adapters: readonly PortAdapter[]; + resolveAsset(asset: string): Promise; + systemInfo(): RemoteSystemInfo; + dispose(): void; +} { + const backend = createSpockBackend(providerConfig(config), host); + const authorityRequirement = host.port("authority"); + const mutationsRequirement = host.port("mutations"); + let authorityContext: PortAdapterContext | null = null; + + const authority: PortAdapter = { + ...authorityRequirement, + async start(context): Promise { + authorityContext = context; + try { + await backend.load(); + context.deliver(observed(backend.authorityValue())); + } catch (error) { + context.deliver( + observed( + wireVariant(AUTHORITY_TYPE, "Failed", [[ + "reason", + wireText(errorMessage(error)), + ]]), + ), + ); + } + }, + accept(): never { + throw new Error("Observation does not accept commands"); + }, + }; + + const mutations: PortAdapter = { + ...mutationsRequirement, + accept(command, context): Promise { + const adapted = adaptRequest(command); + const work = backend.execute(adapted.operation).then((settlement) => { + const result = settlementValue(settlement); + if ( + result.case === "Accepted" + && adapted.operation.kind !== "choose_image_request" + ) { + authorityContext?.deliver(observed(backend.authorityValue())); + } + context.deliver(settled(adapted.request, result)); + }); + return work; + }, + }; + + return { + adapters: [authority, mutations], + resolveAsset: (asset) => backend.resolveAsset(asset), + systemInfo: () => backend.systemInfo(), + dispose: () => backend.dispose(), + }; +} diff --git a/crates/spock-cli/tests/provider_fixtures/view-types.uhura b/crates/spock-cli/tests/provider_fixtures/view-types.uhura new file mode 100644 index 0000000..a839b5b --- /dev/null +++ b/crates/spock-cli/tests/provider_fixtures/view-types.uhura @@ -0,0 +1,37 @@ +pub enum Media { + Image { + image: ImageRef, + }, + Carousel { + images: Seq, + }, + Video { + src: Text, + poster: ImageRef, + }, +} + +pub struct Post { + id: PostId, + author: User, + caption: Text, + media: Media, + like_count: Nat, + comment_count: Nat, + viewer_liked: Bool, + viewer_saved: Bool, + posted_label: Text, +} + +pub struct Profile { + user: User, + bio: Text, + post_count: Nat, + follower_count: Nat, + following_count: Nat, + viewer_follows: Bool, + posts: Seq, + reels: Seq, + tagged: Seq, + saved: Seq, +} diff --git a/crates/spock-cli/tests/provider_gen.rs b/crates/spock-cli/tests/provider_gen.rs new file mode 100644 index 0000000..0aaf60b --- /dev/null +++ b/crates/spock-cli/tests/provider_gen.rs @@ -0,0 +1,121 @@ +//! Provider-generation spike goldens (uhura#29): every generated artifact is +//! char-equal with the hand-written instagram provider it replaces. Fixtures +//! are mechanically extracted from gridaco/uhura examples (MIT © Grida). + +use spock_cli::provider_gen as pg; + +const CONTRACT: &str = include_str!("provider_fixtures/contract.json"); +const WIRE: &str = include_str!("provider_fixtures/instagram.wire"); +const PROVIDER_TS: &str = include_str!("provider_fixtures/spock-provider.ts"); +const MANIFEST: &str = include_str!("provider_fixtures/manifest.toml"); + +fn schema() -> pg::SpockSchema { + pg::extract_contract(CONTRACT).expect("contract parses") +} + +#[test] +fn contract_exposes_the_storage_object_system_table() { + let schema = schema(); + assert_eq!(schema.tables.len(), 11); + assert!(schema.has_table("storage_object")); +} + +#[test] +fn declaration_validates_clean_against_the_contract() { + let file = pg::parse(WIRE).expect("parse"); + let problems = pg::validate_against(&file, &schema()); + assert!(problems.is_empty(), "{problems:?}"); +} + +#[test] +fn machine_contract_types_match_the_handwritten_machine_uhura() { + let file = pg::parse(WIRE).expect("parse"); + let generated = pg::generate_machine_types(&file); + let golden = include_str!("provider_fixtures/machine-types.uhura"); + assert_eq!(generated.trim(), golden.trim()); +} + +#[test] +fn view_types_match_the_handwritten_machine_uhura() { + let file = pg::parse(WIRE).expect("parse"); + let generated = pg::generate_view_types(&file, &schema()).expect("views validate"); + let golden = include_str!("provider_fixtures/view-types.uhura"); + assert_eq!(generated.trim(), golden.trim()); +} + +#[test] +fn snapshot_query_matches_the_handwritten_adapter() { + let file = pg::parse(WIRE).expect("parse"); + let generated = pg::generate_snapshot_query(&file, &schema()).expect("generates"); + let golden = include_str!("provider_fixtures/snapshot-query.graphql"); + assert_eq!(generated, golden); +} + +#[test] +fn dispatch_switch_matches_the_handwritten_adapter() { + let file = pg::parse(WIRE).expect("parse"); + let generated = pg::generate_dispatch(&file).expect("generates"); + let golden = include_str!("provider_fixtures/dispatch-switch.ts"); + assert_eq!(generated, golden); +} + +#[test] +fn refusal_whitelist_semantically_matches_the_handwritten_table() { + let file = pg::parse(WIRE).expect("parse"); + let mut generated = pg::generate_refusals(&file).expect("generates"); + for (_, list) in generated.iter_mut() { + list.sort(); + } + generated.sort(); + + let start = PROVIDER_TS.find("const COMMAND_REFUSALS").expect("table present"); + let end = PROVIDER_TS[start..].find("};").expect("table end") + start; + let block = &PROVIDER_TS[start..end]; + let mut handwritten: Vec<(String, Vec)> = Vec::new(); + for cap in block.split('"').collect::>().chunks(2) { + if cap.len() < 2 { + continue; + } + let token = cap[1]; + if token.contains('/') { + handwritten.push((token.to_string(), Vec::new())); + } else if let Some(last) = handwritten.last_mut() { + last.1.push(token.to_string()); + } + } + for (_, list) in handwritten.iter_mut() { + list.sort(); + } + handwritten.sort(); + assert_eq!(handwritten.len(), 9, "fixture parse sanity"); + assert_eq!(generated, handwritten); +} + +#[test] +fn play_assets_match_the_handwritten_adapter() { + let entries = pg::parse_manifest(MANIFEST).expect("manifest parses"); + let file = pg::parse(WIRE).expect("parse"); + let generated = + pg::generate_play_assets(&entries, &file.videos).expect("no collisions"); + let golden = include_str!("provider_fixtures/play-assets.ts"); + assert_eq!(generated, golden.trim_end()); +} + +#[test] +fn schema_lies_are_refused_with_precise_problems() { + let schema = schema(); + let bad_fn = pg::parse("mutation X { post: post.id } -> call likee_post(post);").unwrap(); + let problems = pg::validate_against(&bad_fn, &schema); + assert!(problems[0].contains("unknown fn `likee_post`"), "{problems:?}"); + + let bad_allow = pg::parse( + "mutation X { post: post.id } -> call like_post(post) route feed/x allow cannot_follow_self;", + ) + .unwrap(); + let problems = pg::validate_against(&bad_allow, &schema); + assert!(problems[0].contains("allows `cannot_follow_self`"), "{problems:?}"); + + let bad_table = pg::parse("snapshot app { cap 200 per table; read ghosts; }").unwrap(); + let err = pg::generate_snapshot_query(&bad_table, &schema).unwrap_err(); + assert!(err[0].contains("unknown table `ghosts`"), "{err:?}"); +} From c46d45744136bbc35ba0ead2e270ec68cf77d7bf Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:56:55 +0900 Subject: [PATCH 2/4] spike: include the shared runtime and its node harness as opt-in evidence The generic runtime (serialized settlement queue, resnapshot-on-accept, whitelist refusal admission) plus its fake-server harness and the live E2E script travel with the spike so the evidence is self-contained; the node-based check is ignore-by-default and never a CI requirement. --- crates/spock-cli/tests/provider_gen.rs | 25 ++++++ .../spock-cli/tests/provider_runtime/e2e.mjs | 78 +++++++++++++++++ .../tests/provider_runtime/runtime-unit.mjs | 68 +++++++++++++++ .../tests/provider_runtime/runtime.mjs | 86 +++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 crates/spock-cli/tests/provider_runtime/e2e.mjs create mode 100644 crates/spock-cli/tests/provider_runtime/runtime-unit.mjs create mode 100644 crates/spock-cli/tests/provider_runtime/runtime.mjs diff --git a/crates/spock-cli/tests/provider_gen.rs b/crates/spock-cli/tests/provider_gen.rs index 0aaf60b..4aa54b6 100644 --- a/crates/spock-cli/tests/provider_gen.rs +++ b/crates/spock-cli/tests/provider_gen.rs @@ -119,3 +119,28 @@ fn schema_lies_are_refused_with_precise_problems() { let err = pg::generate_snapshot_query(&bad_table, &schema).unwrap_err(); assert!(err[0].contains("unknown table `ghosts`"), "{err:?}"); } + +/// Node 기반 실행 하니스 — 명시 실행 전용 (CI는 node를 요구하지 않는다): +/// `cargo test -p spock-cli --test provider_gen -- --ignored` +#[test] +#[ignore = "requires node"] +fn generated_module_drives_the_shared_runtime_under_node() { + let file = pg::parse(WIRE).expect("parse"); + let module = pg::generate_provider_module(&file, &schema()).expect("generates"); + let dir = std::env::temp_dir().join("spock-provider-spike"); + std::fs::create_dir_all(&dir).expect("temp dir"); + let module_path = dir.join("provider-tables.mjs"); + std::fs::write(&module_path, module).expect("write module"); + + let base = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/provider_runtime"); + let output = std::process::Command::new("node") + .arg(format!("{base}/runtime-unit.mjs")) + .arg(&module_path) + .arg(format!("{base}/runtime.mjs")) + .output() + .expect("node must be available for this opt-in check"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("runtime ok"), "{stdout}"); +} diff --git a/crates/spock-cli/tests/provider_runtime/e2e.mjs b/crates/spock-cli/tests/provider_runtime/e2e.mjs new file mode 100644 index 0000000..73a8e7b --- /dev/null +++ b/crates/spock-cli/tests/provider_runtime/e2e.mjs @@ -0,0 +1,78 @@ +// E2E: 생성된 provider 테이블을 실제 spock 백엔드에 대해 검증한다. +// usage: node e2e.mjs +import { strict as assert } from "node:assert"; + +const [base, modulePath] = process.argv.slice(2); +const { SNAPSHOT_QUERY, COMMAND_REFUSALS } = await import(modulePath); + +const gql = async (query) => { + const res = await fetch(`${base}/graphql/v1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query }), + }); + assert.equal(res.status, 200, `graphql http ${res.status}`); + const body = await res.json(); + assert.ok(!body.errors, `graphql errors: ${JSON.stringify(body.errors)}`); + return body.data; +}; + +// 실측 프로토콜: 성공/실패는 HTTP 상태, 본문은 행(또는 {error:{code}}) 그대로. +const rpc = async (fn, args, actor) => { + const headers = { "content-type": "application/json" }; + if (actor) headers["x-spock-actor"] = actor; + const res = await fetch(`${base}/rest/v1/rpc/${fn}`, { + method: "POST", + headers, + body: JSON.stringify(args), + }); + return { ok: res.ok, body: await res.json() }; +}; + +// 1) 생성된 스냅샷 쿼리를 실서버가 수락하고, 시드 수치와 일치하는가 +const data = await gql(SNAPSHOT_QUERY); +const counts = Object.fromEntries( + Object.entries(data).map(([k, v]) => [k, v.length]), +); +const expected = { + users: 9, stories: 12, storyViews: 5, posts: 23, slides: 3, + comments: 13, likes: 52, saves: 3, follows: 36, postTags: 6, +}; +assert.deepEqual(counts, expected, `seed counts: ${JSON.stringify(counts)}`); + +// 2) 실 뮤테이션 왕복: 아직 좋아요 안 한 (user, post) 쌍을 찾아 like → unlike 복원 +const liked = new Set(data.likes.map((l) => `${l.user.id}/${l.post.id}`)); +let actor = null; +let post = null; +for (const u of data.users) { + for (const p of data.posts) { + if (!liked.has(`${u.id}/${p.id}`)) { + actor = u.id; + post = p.id; + break; + } + } + if (actor) break; +} +assert.ok(actor && post, "free (user,post) pair exists"); + +const likeReply = await rpc("like_post", { post }, actor); +assert.ok(likeReply.ok, `like_post: ${JSON.stringify(likeReply)}`); +assert.equal(likeReply.body.post, post, "returned row targets the post"); +const after = await gql(SNAPSHOT_QUERY); +assert.equal(after.likes.length, expected.likes + 1, "like landed"); +const unlikeReply = await rpc("unlike_post", { post }, actor); +assert.ok(unlikeReply.ok, `unlike_post: ${JSON.stringify(unlikeReply)}`); +const restored = await gql(SNAPSHOT_QUERY); +assert.equal(restored.likes.length, expected.likes, "state restored"); + +// 3) 무인증 거절 → 에러 코드가 생성된 화이트리스트와 정합하는가 +const refused = await rpc("like_post", { post }, null); +assert.ok(!refused.ok, "unauthenticated like must be refused"); +const code = String(refused.body.error?.code ?? "").replace(/_/g, "-"); +assert.ok( + COMMAND_REFUSALS["feed/like-post"].includes(code), + `refusal code \`${code}\` must be whitelisted for feed/like-post`, +); + +console.log("e2e ok: snapshot counts, like/unlike round-trip, refusal admission"); diff --git a/crates/spock-cli/tests/provider_runtime/runtime-unit.mjs b/crates/spock-cli/tests/provider_runtime/runtime-unit.mjs new file mode 100644 index 0000000..6436407 --- /dev/null +++ b/crates/spock-cli/tests/provider_runtime/runtime-unit.mjs @@ -0,0 +1,68 @@ +// 공유 런타임 단위 검증 (가짜 서버): 분기 라우팅, 정산 3경로, 직렬화 큐. +// usage: node runtime-unit.mjs +import { strict as assert } from "node:assert"; + +const [tablesPath, runtimePath] = process.argv.slice(2); +const tables = await import(tablesPath); +const { createProvider } = await import(runtimePath); + +const log = []; +let rpcDelay = 0; +let rpcScript = () => ({ ok: true, body: { id: "row" } }); +const fakeFetch = async (url, init) => { + if (url.endsWith("/graphql/v1")) { + log.push("gql"); + return { ok: true, json: async () => ({ data: { posts: [] } }) }; + } + const fn = url.split("/rpc/")[1]; + log.push(`rpc:${fn}:${init.headers["x-spock-actor"] ?? "-"}`); + if (rpcDelay) await new Promise((r) => setTimeout(r, rpcDelay)); + const { ok, body } = rpcScript(fn, JSON.parse(init.body)); + return { ok, json: async () => body }; +}; + +const provider = createProvider({ base: "", tables, fetchImpl: fakeFetch }); + +// 1) 분기: liked=true → like_post / liked=false → unlike_post, 인자 = post id +let r = await provider.dispatch("SetLike", { post: "P1", liked: true }, "U1"); +assert.equal(r.settlement, "accepted"); +r = await provider.dispatch("SetLike", { post: "P1", liked: false }, "U1"); +assert.equal(r.settlement, "accepted"); +assert.deepEqual( + log.filter((l) => l.startsWith("rpc")), + ["rpc:like_post:U1", "rpc:unlike_post:U1"], +); + +// 2) 정산: 수락 경로는 반드시 RPC 후 재스냅샷 +assert.deepEqual(log, ["rpc:like_post:U1", "gql", "rpc:unlike_post:U1", "gql"]); + +// 3) 거절 — 화이트리스트 안: 선언된 이유가 그대로 나온다 +rpcScript = () => ({ ok: false, body: { error: { code: "not_authorized" } } }); +r = await provider.dispatch("SetLike", { post: "P1", liked: true }, null); +assert.deepEqual(r, { settlement: "refused", reason: "not-authorized", declared: true }); + +// 4) 거절 — 화이트리스트 밖: 일반 refused로 뭉개진다 (내부 유출 방지) +rpcScript = () => ({ ok: false, body: { error: { code: "disk_on_fire" } } }); +r = await provider.dispatch("SetLike", { post: "P1", liked: true }, "U1"); +assert.deepEqual(r, { settlement: "refused", reason: "refused", declared: false }); + +// 5) 로컬 모드: RPC 없이 수락 +const before = log.length; +r = await provider.dispatch("LoadMore", {}, "U1"); +assert.deepEqual(r, { settlement: "accepted", local: true }); +assert.equal(log.length, before, "local mutation performs no fetch"); + +// 6) 직렬화: 두 디스패치가 겹치지 않는다 (첫 정산 완료 후 둘째 RPC) +log.length = 0; +rpcScript = () => ({ ok: true, body: {} }); +rpcDelay = 30; +const p1 = provider.dispatch("SetLike", { post: "A", liked: true }, "U1"); +const p2 = provider.dispatch("SetSave", { post: "B", saved: true }, "U1"); +await Promise.all([p1, p2]); +assert.deepEqual( + log, + ["rpc:like_post:U1", "gql", "rpc:save_post:U1", "gql"], + "queue serializes settlements", +); + +console.log("runtime ok: branching, settlement x3, local, serialization"); diff --git a/crates/spock-cli/tests/provider_runtime/runtime.mjs b/crates/spock-cli/tests/provider_runtime/runtime.mjs new file mode 100644 index 0000000..14127b8 --- /dev/null +++ b/crates/spock-cli/tests/provider_runtime/runtime.mjs @@ -0,0 +1,86 @@ +// wire-db 공유 런타임: 앱과 무관한 로직만 소유한다 — 직렬화 큐, 타임아웃, +// 스냅샷 재조회, 정산 유도, 거절 화이트리스트 판정. 앱마다 다른 것(쿼리· +// 디스패치·라우팅·거절 표)은 전부 생성된 tables 모듈에서 주입받는다. + +export function createProvider({ + base = "", + tables, + fetchImpl = globalThis.fetch, + timeoutMs = 15000, +}) { + const helpers = { + keyText: (v) => String(v), + requiredField: (fields, name) => { + if (!(name in fields)) throw new Error(`missing field ${name}`); + return fields[name]; + }, + boolValue: (v) => Boolean(v), + textValue: (v) => String(v), + POST_ID_TYPE: "post", + USER_ID_TYPE: "user", + STORY_ID_TYPE: "story", + }; + + const timed = async (url, init) => { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + return await fetchImpl(url, { ...init, signal: ctrl.signal }); + } finally { + clearTimeout(t); + } + }; + + async function snapshot() { + const res = await timed(`${base}/graphql/v1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: tables.SNAPSHOT_QUERY }), + }); + if (!res.ok) throw new Error(`snapshot http ${res.status}`); + const body = await res.json(); + if (body.errors) throw new Error(`snapshot: ${JSON.stringify(body.errors)}`); + return body.data; + } + + // 뮤테이션은 직렬화된다: 앞선 정산이 끝나기 전에 다음 RPC가 나가지 않는다. + let chain = Promise.resolve(); + function dispatch(mutation, fields, actor) { + const run = chain.then(() => settle(mutation, fields, actor)); + chain = run.catch(() => {}); + return run; + } + + async function settle(mutation, fields, actor) { + const routing = tables.MUTATION_ROUTING[mutation]; + if (!routing) throw new TypeError(`unknown mutation \`${mutation}\``); + const { operation } = tables.toBackendOperation(mutation, null, fields, helpers); + if (routing.mode === "local") return { settlement: "accepted", local: true }; + if (routing.mode === "host") return { settlement: "host-delegated" }; + + const call = + routing.calls.length === 1 + ? routing.calls[0] + : routing.calls.find( + (c) => Boolean(operation[routing.flag]) === c.when, + ); + const args = Object.fromEntries(call.args.map((a) => [a, operation[a]])); + const headers = { "content-type": "application/json" }; + if (actor) headers["x-spock-actor"] = actor; + const res = await timed(`${base}/rest/v1/rpc/${call.fn}`, { + method: "POST", + headers, + body: JSON.stringify(args), + }); + const body = await res.json(); + if (res.ok) { + const data = await snapshot(); + return { settlement: "accepted", data }; + } + const code = String(body.error?.code ?? "").replace(/_/g, "-"); + const declared = (tables.COMMAND_REFUSALS[call.route] ?? []).includes(code); + return { settlement: "refused", reason: declared ? code : "refused", declared }; + } + + return { snapshot, dispatch }; +} From 5f790a3709c0d9f20785bf720a1d635a219568c6 Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:05:12 +0900 Subject: [PATCH 3/4] style: rustfmt the provider spike --- crates/spock-cli/src/provider_gen/mod.rs | 57 +++++++++++------------- crates/spock-cli/tests/provider_gen.rs | 22 ++++++--- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/crates/spock-cli/src/provider_gen/mod.rs b/crates/spock-cli/src/provider_gen/mod.rs index 72c952f..19ccad1 100644 --- a/crates/spock-cli/src/provider_gen/mod.rs +++ b/crates/spock-cli/src/provider_gen/mod.rs @@ -48,7 +48,10 @@ pub struct SettlementDecl { #[derive(Debug, PartialEq)] pub enum ViewEntry { /// 원본 컬럼 그대로, 선택적 투영(`author -> User`). - Column { name: String, projected: Option }, + Column { + name: String, + projected: Option, + }, /// `x = ago(col)` → Text Ago { name: String, column: String }, /// `x = count
where …` → Nat @@ -167,7 +170,11 @@ fn parse_arm(pair: pest::iterators::Pair) -> Result let mut fields = Vec::new(); for vfield in inner { let mut parts = vfield.into_inner(); - let name = parts.next().expect("variant field name").as_str().to_string(); + let name = parts + .next() + .expect("variant field name") + .as_str() + .to_string(); let expr = parts .next() .expect("variant field expr") @@ -207,8 +214,7 @@ fn asset_type(class: &str) -> Option<&'static str> { } pub fn parse(source: &str) -> Result { - let mut pairs = - WireParser::parse(Rule::file, source).map_err(|e| ParseError(e.to_string()))?; + let mut pairs = WireParser::parse(Rule::file, source).map_err(|e| ParseError(e.to_string()))?; let file = pairs.next().expect("file rule"); let mut out = WireFile::default(); @@ -224,8 +230,7 @@ pub fn parse(source: &str) -> Result { Rule::read_decl => { for read in entry.into_inner() { let mut parts = read.into_inner(); - let table = - parts.next().expect("read table").as_str().to_string(); + let table = parts.next().expect("read table").as_str().to_string(); let alias = parts.next().map(|p| p.as_str().to_string()); out.snapshot_reads.push(SnapshotRead { table, alias }); } @@ -272,7 +277,10 @@ pub fn parse(source: &str) -> Result { .as_str() .to_string(); entries.push(match rule { - Rule::ago_e => ViewEntry::Ago { name, column: first }, + Rule::ago_e => ViewEntry::Ago { + name, + column: first, + }, Rule::count_e => ViewEntry::Count { name, table: first }, Rule::exists_e => ViewEntry::Exists { name, table: first }, Rule::tiles_e => ViewEntry::Tiles { name, table: first }, @@ -400,9 +408,7 @@ pub fn policy_calls(policy: &str) -> Vec { let mut route = None; let trimmed = rest.trim_start(); if let Some(after) = trimmed.strip_prefix("route ") { - let token_end = after - .find(char::is_whitespace) - .unwrap_or(after.len()); + let token_end = after.find(char::is_whitespace).unwrap_or(after.len()); route = Some(after[..token_end].to_string()); rest = &after[token_end..]; } @@ -427,9 +433,7 @@ pub fn policy_calls(policy: &str) -> Vec { rest = &rest[consumed.min(rest.len())..]; } if !fn_name.is_empty() { - let when = flag - .as_ref() - .map(|f| (f.clone(), call_index == 0)); + let when = flag.as_ref().map(|f| (f.clone(), call_index == 0)); call_index += 1; out.push(CallSpec { fn_name, @@ -758,8 +762,7 @@ pub fn generate_play_assets( if !problems.is_empty() { return Err(problems); } - let mut out = - String::from("const LOCAL_PLAY_ASSETS: Readonly> = {\n"); + let mut out = String::from("const LOCAL_PLAY_ASSETS: Readonly> = {\n"); for (name, file) in entries.iter().chain(videos) { out.push_str(&format!(" \"{name}\": \"{file}\",\n")); } @@ -801,7 +804,9 @@ pub fn generate_snapshot_query( ) -> Result> { let mut problems = Vec::new(); let Some(cap) = file.snapshot_cap else { - return Err(vec!["snapshot has no `cap N per table` declaration".to_string()]); + return Err(vec![ + "snapshot has no `cap N per table` declaration".to_string() + ]); }; let mut out = String::from("\n query UhuraSnapshot {\n"); for read in &file.snapshot_reads { @@ -815,8 +820,7 @@ pub fn generate_snapshot_query( .unwrap_or_else(|| default_alias(&read.table)); out.push_str(&format!(" {alias}: {}(limit: {cap}) {{\n", read.table)); for column in &table.columns { - let is_ref = - column.base == "storage_object" || schema.has_table(&column.base); + let is_ref = column.base == "storage_object" || schema.has_table(&column.base); if is_ref { out.push_str(&format!(" {} {{ id }}\n", column.name)); } else { @@ -848,10 +852,7 @@ fn pascal(name: &str) -> String { /// 뷰 선언 → 기계 측 레코드 타입 생성. 타입 유도가 스키마와 어긋나면 /// 생성 대신 문제 목록을 돌려준다 (추측 생성 금지). -pub fn generate_view_types( - file: &WireFile, - schema: &SpockSchema, -) -> Result> { +pub fn generate_view_types(file: &WireFile, schema: &SpockSchema) -> Result> { let mut problems = Vec::new(); let mut out = String::new(); for (i, view) in file.views.iter().enumerate() { @@ -1015,10 +1016,7 @@ pub fn generate_view_types( } ViewEntry::Tiles { name, table: t } => { if !schema.has_table(t) { - problems.push(format!( - "view {}: tiles of unknown table `{t}`", - view.name - )); + problems.push(format!("view {}: tiles of unknown table `{t}`", view.name)); } (name.clone(), "Seq".to_string()) } @@ -1108,8 +1106,6 @@ mod tests { // (텍스트 파싱은 storage_object 시스템 테이블을 놓쳤다), additively frozen // 이라 안정된 입력이다. - - #[derive(Debug, Default, PartialEq)] pub struct SpockSchema { pub tables: Vec, @@ -1245,10 +1241,7 @@ pub fn extract_contract(source: &str) -> Result> { .collect() }) .unwrap_or_default(); - let mutating = !f - .get("readonly") - .and_then(Value::as_bool) - .unwrap_or(false); + let mutating = !f.get("readonly").and_then(Value::as_bool).unwrap_or(false); schema.fns.push(SpockFn { name: name.to_string(), errors, diff --git a/crates/spock-cli/tests/provider_gen.rs b/crates/spock-cli/tests/provider_gen.rs index 4aa54b6..d8b112f 100644 --- a/crates/spock-cli/tests/provider_gen.rs +++ b/crates/spock-cli/tests/provider_gen.rs @@ -68,7 +68,9 @@ fn refusal_whitelist_semantically_matches_the_handwritten_table() { } generated.sort(); - let start = PROVIDER_TS.find("const COMMAND_REFUSALS").expect("table present"); + let start = PROVIDER_TS + .find("const COMMAND_REFUSALS") + .expect("table present"); let end = PROVIDER_TS[start..].find("};").expect("table end") + start; let block = &PROVIDER_TS[start..end]; let mut handwritten: Vec<(String, Vec)> = Vec::new(); @@ -95,8 +97,7 @@ fn refusal_whitelist_semantically_matches_the_handwritten_table() { fn play_assets_match_the_handwritten_adapter() { let entries = pg::parse_manifest(MANIFEST).expect("manifest parses"); let file = pg::parse(WIRE).expect("parse"); - let generated = - pg::generate_play_assets(&entries, &file.videos).expect("no collisions"); + let generated = pg::generate_play_assets(&entries, &file.videos).expect("no collisions"); let golden = include_str!("provider_fixtures/play-assets.ts"); assert_eq!(generated, golden.trim_end()); } @@ -106,14 +107,20 @@ fn schema_lies_are_refused_with_precise_problems() { let schema = schema(); let bad_fn = pg::parse("mutation X { post: post.id } -> call likee_post(post);").unwrap(); let problems = pg::validate_against(&bad_fn, &schema); - assert!(problems[0].contains("unknown fn `likee_post`"), "{problems:?}"); + assert!( + problems[0].contains("unknown fn `likee_post`"), + "{problems:?}" + ); let bad_allow = pg::parse( "mutation X { post: post.id } -> call like_post(post) route feed/x allow cannot_follow_self;", ) .unwrap(); let problems = pg::validate_against(&bad_allow, &schema); - assert!(problems[0].contains("allows `cannot_follow_self`"), "{problems:?}"); + assert!( + problems[0].contains("allows `cannot_follow_self`"), + "{problems:?}" + ); let bad_table = pg::parse("snapshot app { cap 200 per table; read ghosts; }").unwrap(); let err = pg::generate_snapshot_query(&bad_table, &schema).unwrap_err(); @@ -141,6 +148,9 @@ fn generated_module_drives_the_shared_runtime_under_node() { .expect("node must be available for this opt-in check"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(output.status.success(), "stdout: {stdout}\nstderr: {stderr}"); + assert!( + output.status.success(), + "stdout: {stdout}\nstderr: {stderr}" + ); assert!(stdout.contains("runtime ok"), "{stdout}"); } From d2c951e961230ef778d0018d2e5ad88223b5a472 Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:26:56 +0900 Subject: [PATCH 4/4] chore: translate all comments to English --- crates/spock-cli/src/provider_gen/mod.rs | 108 +++++++++--------- crates/spock-cli/src/provider_gen/wire.pest | 16 +-- .../tests/provider_fixtures/instagram.wire | 8 +- crates/spock-cli/tests/provider_gen.rs | 2 +- .../spock-cli/tests/provider_runtime/e2e.mjs | 10 +- .../tests/provider_runtime/runtime-unit.mjs | 14 +-- .../tests/provider_runtime/runtime.mjs | 8 +- 7 files changed, 83 insertions(+), 83 deletions(-) diff --git a/crates/spock-cli/src/provider_gen/mod.rs b/crates/spock-cli/src/provider_gen/mod.rs index 19ccad1..895bbd8 100644 --- a/crates/spock-cli/src/provider_gen/mod.rs +++ b/crates/spock-cli/src/provider_gen/mod.rs @@ -1,7 +1,7 @@ -//! Provider generation spike (uhura#29): .wire v0.1 — Spock 스키마 위의 투영·계약 언어. -//! 이 크레이트는 v0.1 범위만 구현한다: .wire 파일을 파싱해 기계 측 -//! 계약 타입(Mutation / Settlement)을 Uhura 0.4 선언문으로 생성한다. -//! 뷰 투영·어댑터 생성은 v0.2 (docs/03 참조). +//! Provider generation spike (uhura#29): .wire — a projection/contract language +//! over the Spock schema. Parses a .wire declaration, validates it against the +//! compiler-emitted contract, and generates the provider artifacts: contract +//! types, view types, snapshot query, dispatch, refusals, assets, and the module. use pest::Parser; use pest_derive::Parser; @@ -14,7 +14,7 @@ struct WireParser; #[derive(Debug, PartialEq)] pub struct Field { pub name: String, - /// `.wire` 원문 타입 표기 (예: `post.id`) — 스키마 대조에 쓴다. + /// Original `.wire` type token (e.g. `post.id`) — used for schema validation. pub source: String, pub ty: String, } @@ -22,7 +22,7 @@ pub struct Field { #[derive(Debug, PartialEq)] pub struct MutationDecl { pub name: String, - /// 백엔드 연산 kind 오버라이드 (`op choose_image_request`); 기본은 snake_case(name) + /// Backend operation kind override (`op choose_image_request`); defaults to snake_case(name) pub op: Option, pub fields: Vec, pub policy: String, @@ -31,11 +31,11 @@ pub struct MutationDecl { #[derive(Debug, PartialEq)] pub struct CallSpec { pub fn_name: String, - /// `if ` 분기의 (플래그 필드, 이 호출이 담당하는 값) + /// For `if ` branches: (flag field, the value this call handles) pub when: Option<(String, bool)>, - /// 호출 인자 이름들 — 연산 객체에서 뽑아 RPC 본문이 된다 + /// Call argument names — extracted from the operation object to form the RPC body pub args: Vec, - /// 거절 화이트리스트의 라우트 키 (`route feed/like-post`) — 명시 선언, 파생 없음 + /// Route key in the refusal whitelist (`route feed/like-post`) — explicit, never derived pub route: Option, pub allows: Vec, } @@ -47,7 +47,7 @@ pub struct SettlementDecl { #[derive(Debug, PartialEq)] pub enum ViewEntry { - /// 원본 컬럼 그대로, 선택적 투영(`author -> User`). + /// Bare source column, with an optional projection (`author -> User`). Column { name: String, projected: Option, @@ -58,7 +58,7 @@ pub enum ViewEntry { Count { name: String, table: String }, /// `x = exists
where …` → Bool Exists { name: String, table: String }, - /// `x = match { … }` → PascalCase(x) 합타입 (팔에서 몸체 생성) + /// `x = match { … }` → PascalCase(x) sum type (variant bodies from the arms) Match { name: String, column: String, @@ -66,7 +66,7 @@ pub enum ViewEntry { }, /// `x = tiles of
…` → Seq Tiles { name: String, table: String }, - /// `x = row as T` → T (행 자체의 투영) + /// `x = row as T` → T (projection of the row itself) RowAs { name: String, ty: String }, } @@ -79,9 +79,9 @@ pub struct MatchArm { #[derive(Debug, PartialEq)] pub enum VariantField { - /// `as ` — 뷰 원본 테이블의 컬럼을 자산 클래스로 투영 + /// `as ` — project a column of the view's source table as an asset class Scalar { column: String, class: String }, - /// `each
.as …` — 하위 행들의 시퀀스 투영 + /// `each
.as …` — sequence projection over child rows Each { table: String, column: String, @@ -99,14 +99,14 @@ pub struct ViewDecl { #[derive(Debug, PartialEq)] pub struct SnapshotRead { pub table: String, - /// `read carousel_slide as slides` — 불규칙 별칭의 명시 오버라이드. + /// `read carousel_slide as slides` — explicit override for irregular aliases. pub alias: Option, } #[derive(Debug, Default)] pub struct WireFile { pub app: Option, - /// fixtures 블록의 명시 비디오 매핑 (매니페스트에 파생원 없음) + /// Explicit video mappings from the fixtures block (no manifest source to derive from) pub videos: Vec<(String, String)>, pub snapshot_cap: Option, pub snapshot_reads: Vec, @@ -125,8 +125,8 @@ impl std::fmt::Display for ParseError { } impl std::error::Error for ParseError {} -/// `.wire` 필드 타입 → 기계 측 타입 이름. -/// `
.id`는 `
Id`로 투영되고, 스칼라는 0.4 프렐류드 이름을 쓴다. +/// `.wire` field type → machine-side type name. +/// `
.id` projects to `
Id`; scalars use the 0.4 prelude names. fn machine_type(ty: &str) -> Result { match ty { "text" => Ok("Text".to_string()), @@ -204,7 +204,7 @@ fn parse_arm(pair: pest::iterators::Pair) -> Result }) } -/// 자산 클래스 → 기계 타입. storage_object 컬럼만 자산 투영이 가능하다. +/// Asset class → machine type. Only storage_object columns can be asset-projected. fn asset_type(class: &str) -> Option<&'static str> { match class { "image" => Some("ImageRef"), @@ -364,11 +364,11 @@ pub fn parse(source: &str) -> Result { Ok(out) } -/// 정책 문자열에서 `call (...) [route g/name] allow e1, e2` 절을 추출한다. -/// `local {...}` / `host {...}` 정책은 호출이 없으므로 빈 목록. +/// Extract `call (...) [route g/name] allow e1, e2` clauses from a policy string. +/// `local {...}` / `host {...}` policies have no calls, so the list is empty. pub fn policy_calls(policy: &str) -> Vec { let mut out = Vec::new(); - // `if call A(...) ... else call B(...)` — 첫 호출=true, 둘째=false + // `if call A(...) ... else call B(...)` — first call handles true, second false let flag = policy.trim_start().strip_prefix("if ").map(|rest| { let end = rest .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) @@ -378,7 +378,7 @@ pub fn policy_calls(policy: &str) -> Vec { let mut call_index = 0usize; let mut rest = policy; while let Some(pos) = rest.find("call ") { - // "call"이 식별자 일부가 아니어야 한다 + // "call" must not be part of a longer identifier if pos > 0 && rest[..pos] .chars() @@ -447,8 +447,8 @@ pub fn policy_calls(policy: &str) -> Vec { out } -/// 뮤테이션 → 백엔드 라우팅 표 (JSON). 런타임이 이 표만 보고 -/// 분기·RPC 인자·거절 라우트를 결정한다 — 로직은 소유하지 않는다. +/// Mutation → backend routing table (JSON). The runtime decides branches, RPC +/// arguments, and refusal routes from this table alone — it owns no logic. pub fn generate_routing(file: &WireFile) -> String { let mut out = String::from("{\n"); for (i, m) in file.mutations.iter().enumerate() { @@ -506,8 +506,8 @@ pub fn generate_routing(file: &WireFile) -> String { out } -/// 스키마 대조: .wire가 참조하는 테이블·fn·에러가 계약에 실존하는지. -/// 위반은 사람이 읽을 수 있는 문장 목록으로 돌려준다 (조용한 통과 금지). +/// Schema validation: every table/fn/error a .wire file references must exist +/// in the contract. Violations come back as a readable list (no silent passes). pub fn validate_against(file: &WireFile, schema: &SpockSchema) -> Vec { let mut problems = Vec::new(); for read in &file.snapshot_reads { @@ -552,7 +552,7 @@ pub fn validate_against(file: &WireFile, schema: &SpockSchema) -> Vec { problems } -/// 스칼라 컬럼 타입 → 기계 타입. FK/enum은 여기 오지 않는다. +/// Scalar column type → machine type. FK/enum columns never reach here. fn column_machine_type(table: &str, column: &SpockColumn) -> Option { if column.key && column.base == "uuid" { let mut chars = table.chars(); @@ -590,8 +590,8 @@ fn screaming(name: &str) -> String { name.to_ascii_uppercase() } -/// 뮤테이션 표면 → 어댑터의 `toBackendOperation` 스위치 본문 (TS 텍스트). -/// kind = `op` 오버라이드 또는 snake_case(이름); 필드 변환은 타입에서 유도. +/// Mutation surface → the adapter's `toBackendOperation` switch body (TS text). +/// kind = `op` override or snake_case(name); field mapping is derived from types. pub fn generate_dispatch(file: &WireFile) -> Result> { let mut problems = Vec::new(); let Some(app) = &file.app else { @@ -642,7 +642,7 @@ pub fn generate_dispatch(file: &WireFile) -> Result> { } } -/// 라우트 키 → kebab 거절 목록. 중복 라우트는 에러. +/// Route key → kebab-case refusal list. Duplicate routes are an error. pub fn generate_refusals(file: &WireFile) -> Result)>, Vec> { let mut problems = Vec::new(); let mut out: Vec<(String, Vec)> = Vec::new(); @@ -663,8 +663,8 @@ pub fn generate_refusals(file: &WireFile) -> Result)>, } } -/// S1+S2 산출물을 실행 가능한 provider 테이블 모듈(ESM JS)로 조립한다. -/// 로직(큐·정산)은 공유 런타임 몫이고, 이 모듈은 데이터와 순수 디스패치만 담는다. +/// Assemble the generated artifacts into a runnable provider tables module (ESM JS). +/// Logic (queue/settlement) belongs to the shared runtime; this module carries data and pure dispatch only. pub fn generate_provider_module( file: &WireFile, schema: &SpockSchema, @@ -713,8 +713,8 @@ pub fn generate_provider_module( Ok(out) } -/// 자산 매니페스트(manifest.toml)의 `[assets.]` + `file = "..."` 쌍을 -/// 추출한다. alt/size/sha256 등은 gen-assets 도구 소유라 여기서 읽지 않는다. +/// Extract `[assets.]` + `file = "..."` pairs from the asset manifest +/// (manifest.toml). alt/size/sha256 belong to the gen-assets tool and are not read here. pub fn parse_manifest(source: &str) -> Result, Vec> { let mut problems = Vec::new(); let mut out: Vec<(String, String)> = Vec::new(); @@ -747,8 +747,8 @@ pub fn parse_manifest(source: &str) -> Result, Vec } } -/// 매니페스트 항목 + .wire의 명시 비디오 매핑 → Play 자산 논리명 표 (TS 텍스트). -/// 이름 충돌은 에러. +/// Manifest entries + explicit .wire video mappings → Play logical-asset table (TS text). +/// Name collisions are an error. pub fn generate_play_assets( entries: &[(String, String)], videos: &[(String, String)], @@ -790,14 +790,14 @@ fn pluralize(name: &str) -> String { } } -/// 스냅샷 항목의 기본 별칭: camelCase 복수형 (user→users, story→stories, -/// story_view→storyViews). 이 규칙을 벗어나는 별칭은 `as`로 명시해야 한다. +/// Default alias for a snapshot entry: camelCase plural (user→users, story→stories, +/// story_view→storyViews). Aliases outside this rule must be declared with `as`. pub fn default_alias(table: &str) -> String { pluralize(&camel(table)) } -/// .wire 스냅샷 선언 + 스키마 컬럼 → 어댑터의 GraphQL 스냅샷 문서. -/// FK와 storage_object 컬럼은 `name { id }`로, 나머지는 bare로 투영된다. +/// .wire snapshot declaration + schema columns → the adapter's GraphQL snapshot document. +/// FK and storage_object columns project as `name { id }`; the rest stay bare. pub fn generate_snapshot_query( file: &WireFile, schema: &SpockSchema, @@ -850,8 +850,8 @@ fn pascal(name: &str) -> String { .collect() } -/// 뷰 선언 → 기계 측 레코드 타입 생성. 타입 유도가 스키마와 어긋나면 -/// 생성 대신 문제 목록을 돌려준다 (추측 생성 금지). +/// View declaration → machine-side record types. If type derivation disagrees with +/// the schema, return a problem list instead of generating (no speculative output). pub fn generate_view_types(file: &WireFile, schema: &SpockSchema) -> Result> { let mut problems = Vec::new(); let mut out = String::new(); @@ -1054,8 +1054,8 @@ fn emit_variant(out: &mut String, name: &str, fields: &[Field]) { } } -/// 기계 측 계약 선언문 생성. Settlement의 Accepted/Refused는 0.4 결과 어휘에 -/// 고정된 규약이므로 언어가 강제하고, extras만 파일 선언을 따른다. +/// Generate the machine-side contract declarations. Settlement's Accepted/Refused are +/// fixed by the 0.4 result vocabulary, so they are enforced; only extras follow the file. pub fn generate_machine_types(file: &WireFile) -> String { let mut out = String::from("pub enum Mutation {\n"); for m in &file.mutations { @@ -1101,10 +1101,10 @@ mod tests { assert!(parse("mutation { post: post.id }").is_err()); } } -// 스키마 입력: 컴파일러가 방출한 계약 JSON(`spock build` / GET /~contract)을 -// 소비한다. .spock 소스 텍스트를 직접 파싱하지 않는다 — 계약이 진실이고 -// (텍스트 파싱은 storage_object 시스템 테이블을 놓쳤다), additively frozen -// 이라 안정된 입력이다. +// Schema input: consume the compiler-emitted contract JSON (`spock build` / +// GET /~contract). We never parse .spock source text — the contract is the +// truth (text parsing missed the storage_object system table) and, being +// additively frozen, a stable input. #[derive(Debug, Default, PartialEq)] pub struct SpockSchema { @@ -1122,8 +1122,8 @@ pub struct SpockTable { #[derive(Debug, PartialEq)] pub struct SpockColumn { pub name: String, - /// 기본 타입 토큰: uuid/text/timestamp/bool/int, FK면 대상 테이블 이름 - /// (storage_object 포함), 인라인 열거(set)면 "enum". + /// Base type token: uuid/text/timestamp/bool/int, the target table name for + /// FKs (including storage_object), or "enum" for inline enumerations (set). pub base: String, pub key: bool, } @@ -1161,7 +1161,7 @@ fn column_base(ty: &Value) -> Option { } } -/// 계약 JSON → 스키마. 모양이 어긋나면 추측하지 않고 문제 목록을 돌려준다. +/// Contract JSON → schema. On shape mismatch, return a problem list instead of guessing. pub fn extract_contract(source: &str) -> Result> { let root: Value = match serde_json::from_str(source) { Ok(v) => v, @@ -1260,8 +1260,8 @@ pub fn extract_contract(source: &str) -> Result> { // ===== CLI seam: typed contract + declaration path → provider module ===== -/// `spock gen provider --app `: 계약(컴파일러 소유)과 앱 선언에서 -/// provider 테이블 모듈을 생성한다. 문제는 사람이 읽을 목록으로 합쳐 실패시킨다. +/// `spock gen provider --app `: generate the provider tables module +/// from the contract (compiler-owned) and the app declaration; problems merge into one failure. pub fn generate_from_contract( contract: &C, app_declaration: &std::path::Path, diff --git a/crates/spock-cli/src/provider_gen/wire.pest b/crates/spock-cli/src/provider_gen/wire.pest index df16675..2739b05 100644 --- a/crates/spock-cli/src/provider_gen/wire.pest +++ b/crates/spock-cli/src/provider_gen/wire.pest @@ -8,7 +8,7 @@ ident = @{ (ASCII_ALPHANUMERIC | "_")+ } dotted = @{ ident ~ ("." ~ ident)* } string = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" } -// 균형 잡힌 원시 블록: v0.1이 해석하지 않는 본문(뷰/스냅샷/픽스처)을 통째로 보존 +// Balanced raw block: preserve uninterpreted bodies (views/snapshot/fixtures) verbatim rawblock = @{ "{" ~ (rawblock | !("{" | "}") ~ ANY)* ~ "}" } use_decl = { "use" ~ ident ~ string ~ ";" } @@ -20,8 +20,8 @@ read_entry = { ident ~ ("as" ~ ident)? } read_decl = { "read" ~ read_entry ~ ("," ~ read_entry)* ~ ";" } snapshot = { "snapshot" ~ ident ~ "{" ~ (cap_decl | read_decl)* ~ "}" } -// 뷰: 컬럼 항목과 파생 항목. 조건절(where …)은 v0.1이 타입만 유도하고 -// 본문은 원시 보존한다. +// Views: column entries and derived entries. For where-clauses, only types are +// derived and the body is preserved raw. raw_cond = @{ (!(";" | "{" | "}") ~ ANY)* } raw_tail = @{ (!("," | ";" | "{" | "}") ~ ANY)* } ago_e = { "ago" ~ "(" ~ ident ~ ")" } @@ -30,8 +30,8 @@ exists_e = { "exists" ~ ident ~ raw_cond } tiles_e = { "tiles" ~ "of" ~ ident ~ raw_cond } rowas_e = { "row" ~ "as" ~ ident } -// match 팔: 태그 문자열 → 변형 { 필드: 자산 투영 }. -// 자산 클래스(`as image`/`as url`)가 storage_object의 기계 타입을 결정한다. +// match arms: tag string → variant { field: asset projection }. +// The asset class (`as image`/`as url`) decides the storage_object machine type. each_e = { "each" ~ ident ~ "." ~ ident ~ "as" ~ ident ~ raw_tail } asof_e = { ident ~ "as" ~ ident } vfexpr = { each_e | asof_e } @@ -42,16 +42,16 @@ vexpr = { ago_e | count_e | exists_e | match_e | tiles_e | rowas_e } computed = { ident ~ "=" ~ vexpr ~ ";" } column = { ident ~ ("->" ~ ident)? ~ ";" } view = { "view" ~ ident ~ "from" ~ ident ~ "{" ~ (computed | column)* ~ "}" } -// 비디오는 매니페스트에 파생원이 없어 명시 선언한다. +// Videos have no manifest source to derive from, so they are declared explicitly. video_decl = { "video" ~ string ~ "=" ~ string ~ ";" } fixtures = { "fixtures" ~ "from" ~ dotted ~ "{" ~ (video_decl | raw_stmt)* ~ "}" } field = { ident ~ ":" ~ dotted } fields = { "{" ~ (field ~ ("," ~ field)* ~ ","?)? ~ "}" } -// 정책: `->` 뒤부터 깊이 0의 `;`까지 (중괄호 안 `;`는 소비) +// Policy: from after `->` to the depth-0 `;` (consumes `;` inside braces) policy = @{ "->" ~ (rawblock | !(";" | "{" | "}") ~ ANY)* } -// `op`: 백엔드 연산 kind 오버라이드 (기본값 = 뮤테이션 이름의 snake_case) +// `op`: backend operation kind override (default = snake_case of the mutation name) mutation = { "mutation" ~ ident ~ ("op" ~ ident)? ~ fields? ~ policy? ~ ";" } extra_decl = { "extra" ~ ident ~ fields ~ ";" } diff --git a/crates/spock-cli/tests/provider_fixtures/instagram.wire b/crates/spock-cli/tests/provider_fixtures/instagram.wire index d3c87ff..8f4df44 100644 --- a/crates/spock-cli/tests/provider_fixtures/instagram.wire +++ b/crates/spock-cli/tests/provider_fixtures/instagram.wire @@ -1,8 +1,8 @@ -// instagram.wire — v0.1 (파서 대상) -// 역할: Spock 스키마를 재선언하지 않고(이중 권위 금지) 그 위의 -// 투영·계약 계층을 선언한다. v0.1 생성물 = 기계 측 계약 타입. +// instagram.wire — parser target +// Role: declare the projection/contract layer over the Spock schema without +// redeclaring it (no dual authority). -use contract "contract.json"; // spock build 산출물 (또는 GET /~contract) +use contract "contract.json"; // spock build output (or GET /~contract) app "Instagram"; snapshot app { diff --git a/crates/spock-cli/tests/provider_gen.rs b/crates/spock-cli/tests/provider_gen.rs index d8b112f..11f30d5 100644 --- a/crates/spock-cli/tests/provider_gen.rs +++ b/crates/spock-cli/tests/provider_gen.rs @@ -127,7 +127,7 @@ fn schema_lies_are_refused_with_precise_problems() { assert!(err[0].contains("unknown table `ghosts`"), "{err:?}"); } -/// Node 기반 실행 하니스 — 명시 실행 전용 (CI는 node를 요구하지 않는다): +/// Node-based execution harness — opt-in only (CI does not require node): /// `cargo test -p spock-cli --test provider_gen -- --ignored` #[test] #[ignore = "requires node"] diff --git a/crates/spock-cli/tests/provider_runtime/e2e.mjs b/crates/spock-cli/tests/provider_runtime/e2e.mjs index 73a8e7b..82260c1 100644 --- a/crates/spock-cli/tests/provider_runtime/e2e.mjs +++ b/crates/spock-cli/tests/provider_runtime/e2e.mjs @@ -1,4 +1,4 @@ -// E2E: 생성된 provider 테이블을 실제 spock 백엔드에 대해 검증한다. +// E2E: verify the generated provider tables against a real spock backend. // usage: node e2e.mjs import { strict as assert } from "node:assert"; @@ -17,7 +17,7 @@ const gql = async (query) => { return body.data; }; -// 실측 프로토콜: 성공/실패는 HTTP 상태, 본문은 행(또는 {error:{code}}) 그대로. +// Measured protocol: success/failure is the HTTP status; the body is the raw row (or {error:{code}}). const rpc = async (fn, args, actor) => { const headers = { "content-type": "application/json" }; if (actor) headers["x-spock-actor"] = actor; @@ -29,7 +29,7 @@ const rpc = async (fn, args, actor) => { return { ok: res.ok, body: await res.json() }; }; -// 1) 생성된 스냅샷 쿼리를 실서버가 수락하고, 시드 수치와 일치하는가 +// 1) The real server accepts the generated snapshot query and the counts match the seed const data = await gql(SNAPSHOT_QUERY); const counts = Object.fromEntries( Object.entries(data).map(([k, v]) => [k, v.length]), @@ -40,7 +40,7 @@ const expected = { }; assert.deepEqual(counts, expected, `seed counts: ${JSON.stringify(counts)}`); -// 2) 실 뮤테이션 왕복: 아직 좋아요 안 한 (user, post) 쌍을 찾아 like → unlike 복원 +// 2) Real mutation round-trip: find a (user, post) pair without a like, then like → unlike to restore const liked = new Set(data.likes.map((l) => `${l.user.id}/${l.post.id}`)); let actor = null; let post = null; @@ -66,7 +66,7 @@ assert.ok(unlikeReply.ok, `unlike_post: ${JSON.stringify(unlikeReply)}`); const restored = await gql(SNAPSHOT_QUERY); assert.equal(restored.likes.length, expected.likes, "state restored"); -// 3) 무인증 거절 → 에러 코드가 생성된 화이트리스트와 정합하는가 +// 3) Unauthenticated refusal → the error code must agree with the generated whitelist const refused = await rpc("like_post", { post }, null); assert.ok(!refused.ok, "unauthenticated like must be refused"); const code = String(refused.body.error?.code ?? "").replace(/_/g, "-"); diff --git a/crates/spock-cli/tests/provider_runtime/runtime-unit.mjs b/crates/spock-cli/tests/provider_runtime/runtime-unit.mjs index 6436407..aecb813 100644 --- a/crates/spock-cli/tests/provider_runtime/runtime-unit.mjs +++ b/crates/spock-cli/tests/provider_runtime/runtime-unit.mjs @@ -1,4 +1,4 @@ -// 공유 런타임 단위 검증 (가짜 서버): 분기 라우팅, 정산 3경로, 직렬화 큐. +// Shared-runtime unit checks (fake server): branch routing, three settlement paths, serialized queue. // usage: node runtime-unit.mjs import { strict as assert } from "node:assert"; @@ -23,7 +23,7 @@ const fakeFetch = async (url, init) => { const provider = createProvider({ base: "", tables, fetchImpl: fakeFetch }); -// 1) 분기: liked=true → like_post / liked=false → unlike_post, 인자 = post id +// 1) Branching: liked=true → like_post / liked=false → unlike_post, argument = post id let r = await provider.dispatch("SetLike", { post: "P1", liked: true }, "U1"); assert.equal(r.settlement, "accepted"); r = await provider.dispatch("SetLike", { post: "P1", liked: false }, "U1"); @@ -33,26 +33,26 @@ assert.deepEqual( ["rpc:like_post:U1", "rpc:unlike_post:U1"], ); -// 2) 정산: 수락 경로는 반드시 RPC 후 재스냅샷 +// 2) Settlement: the accept path must re-snapshot after the RPC assert.deepEqual(log, ["rpc:like_post:U1", "gql", "rpc:unlike_post:U1", "gql"]); -// 3) 거절 — 화이트리스트 안: 선언된 이유가 그대로 나온다 +// 3) Refusal inside the whitelist: the declared reason passes through as-is rpcScript = () => ({ ok: false, body: { error: { code: "not_authorized" } } }); r = await provider.dispatch("SetLike", { post: "P1", liked: true }, null); assert.deepEqual(r, { settlement: "refused", reason: "not-authorized", declared: true }); -// 4) 거절 — 화이트리스트 밖: 일반 refused로 뭉개진다 (내부 유출 방지) +// 4) Refusal outside the whitelist: collapsed to a generic refusal (no internal leakage) rpcScript = () => ({ ok: false, body: { error: { code: "disk_on_fire" } } }); r = await provider.dispatch("SetLike", { post: "P1", liked: true }, "U1"); assert.deepEqual(r, { settlement: "refused", reason: "refused", declared: false }); -// 5) 로컬 모드: RPC 없이 수락 +// 5) Local mode: accepted without an RPC const before = log.length; r = await provider.dispatch("LoadMore", {}, "U1"); assert.deepEqual(r, { settlement: "accepted", local: true }); assert.equal(log.length, before, "local mutation performs no fetch"); -// 6) 직렬화: 두 디스패치가 겹치지 않는다 (첫 정산 완료 후 둘째 RPC) +// 6) Serialization: two dispatches never overlap (second RPC only after the first settles) log.length = 0; rpcScript = () => ({ ok: true, body: {} }); rpcDelay = 30; diff --git a/crates/spock-cli/tests/provider_runtime/runtime.mjs b/crates/spock-cli/tests/provider_runtime/runtime.mjs index 14127b8..1dfb62a 100644 --- a/crates/spock-cli/tests/provider_runtime/runtime.mjs +++ b/crates/spock-cli/tests/provider_runtime/runtime.mjs @@ -1,6 +1,6 @@ -// wire-db 공유 런타임: 앱과 무관한 로직만 소유한다 — 직렬화 큐, 타임아웃, -// 스냅샷 재조회, 정산 유도, 거절 화이트리스트 판정. 앱마다 다른 것(쿼리· -// 디스패치·라우팅·거절 표)은 전부 생성된 tables 모듈에서 주입받는다. +// Shared runtime: owns only the app-independent logic — the serialized queue, +// timeouts, snapshot refetch, settlement derivation, refusal-whitelist decisions. +// Everything app-specific (query, dispatch, routing, refusals) comes from the generated tables module. export function createProvider({ base = "", @@ -43,7 +43,7 @@ export function createProvider({ return body.data; } - // 뮤테이션은 직렬화된다: 앞선 정산이 끝나기 전에 다음 RPC가 나가지 않는다. + // Mutations are serialized: the next RPC does not go out before the previous settlement completes. let chain = Promise.resolve(); function dispatch(mutation, fields, actor) { const run = chain.then(() => settle(mutation, fields, actor));