1use std::any::Any;
6use std::collections::{HashMap, HashSet};
7use std::sync::{
8 atomic::{AtomicBool, Ordering},
9 Arc,
10};
11
12use khive_storage::{
13 AtomicUnitOp, Note, SqlAccess, SqlStatement, SqlValue, SqlWriter, StorageCapability,
14 StorageError, WriterTaskRequestState,
15};
16use khive_types::{Details, KhiveError};
17use serde::Deserialize;
18use serde_json::{json, Value};
19use uuid::Uuid;
20
21use crate::atomic_message::{
22 prepare_atomic_note_requests, AtomicNoteOptions, AtomicNoteRequest, AtomicNoteSpec,
23};
24use crate::atomic_plan::{PlanStatement, PostCommitEffect};
25use crate::atomic_runner::{
26 apply_plan, run_prepared_atomic_unit, AtomicOpFailure, AtomicOpPlan,
27 CommittedPostCommitEffects, PreparedAtomicError, PreparedAtomicOp, PreparedAtomicOutcome,
28};
29use crate::note_write::{
30 NoteFence, NoteFences, NoteWriteConflict, NoteWriteGuard, NoteWriteOptions,
31};
32use crate::{
33 micros_to_iso, DomainDisposition, KhiveRuntime, NamespaceToken, RuntimeError, RuntimeResult,
34 VerbRegistry,
35};
36
37#[cfg(test)]
38mod append_failure_tests;
39
40fn validate_stream(stream: &str) -> RuntimeResult<()> {
41 if stream.len() > 512 || stream.contains('\0') {
42 return Err(RuntimeError::InvalidInput(
43 "stream must be at most 512 UTF-8 bytes and contain no U+0000".into(),
44 ));
45 }
46 Ok(())
47}
48
49fn write_failure(message: &str) -> StorageError {
50 StorageError::Conflict {
51 capability: StorageCapability::Notes,
52 operation: "stream.append".into(),
53 message: message.into(),
54 }
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum StreamAppendDisposition {
61 NotCommitted,
63 Unknown,
65}
66
67#[derive(Debug, thiserror::Error)]
69#[error("{source}")]
70pub struct StreamAppendFailure {
71 #[source]
72 source: RuntimeError,
73 disposition: StreamAppendDisposition,
74}
75
76impl StreamAppendFailure {
77 fn not_committed(source: RuntimeError) -> Self {
78 Self {
79 source,
80 disposition: StreamAppendDisposition::NotCommitted,
81 }
82 }
83
84 pub fn unknown(source: RuntimeError) -> Self {
87 Self {
88 source,
89 disposition: StreamAppendDisposition::Unknown,
90 }
91 }
92
93 fn after_submission(source: RuntimeError, refused_before_write: bool) -> Self {
94 let disposition = if let Some(context) = source.writer_task_failure_context() {
95 match context.request_state {
96 WriterTaskRequestState::NotStarted
97 | WriterTaskRequestState::TransactionRolledBack => {
98 StreamAppendDisposition::NotCommitted
99 }
100 WriterTaskRequestState::SideEffectsUnknown => StreamAppendDisposition::Unknown,
101 }
102 } else if refused_before_write
103 || source.admission_failure_context().is_some()
104 || matches!(
105 &source,
106 RuntimeError::Storage(error) if matches!(error.without_sqlite_write_stage(), StorageError::WriterTaskBusy { .. })
107 )
108 {
109 StreamAppendDisposition::NotCommitted
110 } else {
111 StreamAppendDisposition::Unknown
112 };
113 Self {
114 source,
115 disposition,
116 }
117 }
118
119 pub fn source(&self) -> &RuntimeError {
121 &self.source
122 }
123
124 pub fn disposition(&self) -> StreamAppendDisposition {
126 self.disposition
127 }
128
129 pub fn into_parts(self) -> (RuntimeError, StreamAppendDisposition) {
131 (self.source, self.disposition)
132 }
133
134 pub fn into_source(self) -> RuntimeError {
136 self.source
137 }
138}
139
140pub struct StreamAppendSpec {
143 pub stream: String,
144 pub record: Value,
145 pub expected_seq: Option<i64>,
146 pub embed: Option<bool>,
147 pub embedding_model: Option<String>,
148 pub note_kind: String,
149 pub tags: Option<Vec<String>>,
150 pub fence: Option<NoteFences>,
151}
152
153impl StreamAppendSpec {
154 fn validate_embedding(&self, member: Option<usize>) -> RuntimeResult<()> {
155 if self.embedding_model.is_some() && self.embed != Some(true) {
156 let mut error = KhiveError::invalid_input("embedding_model requires embed=true");
157 if let Some(member) = member {
158 error = error.with_details(Details::new_owned([("member", member.to_string())]));
159 }
160 return Err(error.into());
161 }
162 Ok(())
163 }
164
165 fn note_options(&self) -> AtomicNoteOptions<'_> {
166 AtomicNoteOptions {
167 embed: Some(self.embed.unwrap_or(false)),
168 embedding_model: self.embedding_model.as_deref(),
169 ..Default::default()
170 }
171 }
172}
173
174#[derive(Clone, Debug)]
177pub struct StreamWriteSpec {
178 pub key: String,
179 pub kind: String,
180 pub doc: Value,
181 pub tags: Option<Vec<String>>,
182 pub embed: Option<bool>,
183 pub expected_version: Option<i64>,
184}
185
186#[derive(Clone, Debug)]
189pub struct StreamObservation {
190 pub key: String,
191 pub kind: String,
192 pub version: Option<i64>,
193 pub id: Option<Uuid>,
194 pub live_until: Option<String>,
196}
197
198pub enum StreamBatchMember {
200 Append(StreamAppendSpec),
201 Write(StreamWriteSpec),
202 Refused(KhiveError),
203}
204
205const MAX_BATCH_FENCE_ENTRIES: usize = 100;
208
209fn stream_batch_fence_count(members: &[StreamBatchMember], fence: Option<&NoteFence>) -> usize {
210 usize::from(fence.is_some())
211 + members
212 .iter()
213 .filter_map(|member| match member {
214 StreamBatchMember::Append(spec) => spec.fence.as_ref(),
215 StreamBatchMember::Write(_) | StreamBatchMember::Refused(_) => None,
216 })
217 .map(|fences| fences.entries().len())
218 .sum::<usize>()
219}
220
221fn validate_stream_batch_fence_count(count: usize) -> RuntimeResult<()> {
222 if count > MAX_BATCH_FENCE_ENTRIES {
223 return Err(RuntimeError::InvalidInput(format!(
224 "stream.batch admits at most {MAX_BATCH_FENCE_ENTRIES} total fence entries; this call sent {count}: each fence is a read taken while holding the writer"
225 )));
226 }
227 Ok(())
228}
229
230#[derive(Debug)]
232pub struct StreamBatchRefusal {
233 pub member: usize,
234 pub error: KhiveError,
235}
236
237pub fn refusal_value(error: &KhiveError) -> RuntimeResult<Value> {
240 let mut value = serde_json::to_value(error)
241 .map_err(|e| RuntimeError::Internal(format!("stream refusal serialization: {e}")))?;
242 value["domain_disposition"] = json!(DomainDisposition::NotCommitted.as_str());
243 Ok(value)
244}
245
246fn seq_conflict(stream: &str, expected: i64, next: i64, member: Option<usize>) -> KhiveError {
247 let mut pairs = vec![
248 ("reason", "seq_conflict".to_string()),
249 ("stream", stream.to_string()),
250 ("expected_seq", expected.to_string()),
251 ("next_seq", next.to_string()),
252 ];
253 if let Some(member) = member {
254 pairs.push(("member", member.to_string()));
255 }
256 KhiveError::conflict("stream sequence precondition failed")
257 .with_details(Details::new_owned(pairs))
258}
259
260struct PreparedAppend {
262 stream: String,
263 expected_seq: Option<i64>,
264 note: Note,
265 statements: Vec<PlanStatement>,
266 guard: NoteWriteGuard,
267}
268
269fn append_result(prepared: &PreparedAppend, seq: i64) -> Value {
270 json!({"seq": seq, "id": prepared.note.id, "created_at": micros_to_iso(prepared.note.created_at)})
271}
272
273enum BatchOutcome {
274 Appended(Vec<i64>),
275 Conflict { next: i64 },
276 FenceConflict { conflict: NoteWriteConflict },
277}
278
279struct PreparedBatchMember {
280 index: usize,
281 action: PreparedBatchAction,
282}
283
284enum PreparedBatchAction {
285 Append {
286 stream: String,
287 expected_seq: Option<i64>,
288 note: Box<Note>,
289 plan: AtomicOpPlan,
290 fence: Option<NoteFences>,
291 },
292 Write {
293 key: String,
294 kind: String,
295 id: Uuid,
296 plan: AtomicOpPlan,
297 after_create: Option<Value>,
298 },
299 Refused(KhiveError),
300}
301
302#[derive(Deserialize)]
303struct StreamCreateFields {
304 content: String,
305 name: Option<String>,
306 properties: Option<Value>,
307 tags: Option<Vec<String>>,
308 salience: Option<f64>,
309 decay_factor: Option<f64>,
310 embed: Option<bool>,
311}
312
313fn normalize_stream_create_fields(fields: &mut StreamCreateFields) -> RuntimeResult<()> {
314 let tags = fields.tags.take();
315 if let crate::EffectiveCreateTags::TopLevel(tags) =
316 crate::effective_create_tags(tags.as_deref(), fields.properties.as_ref())
317 {
318 let tags = json!(tags);
319 let mut properties = match fields.properties.take() {
320 None => serde_json::Map::new(),
321 Some(Value::Object(properties)) => properties,
322 Some(_) => {
323 return Err(RuntimeError::InvalidInput(
324 "note tags require object properties".into(),
325 ))
326 }
327 };
328 properties.insert("tags".into(), tags);
329 fields.properties = Some(Value::Object(properties));
330 }
331 Ok(())
332}
333
334struct PreparedStreamCreate {
335 key: String,
336 kind: String,
337 fields: StreamCreateFields,
338 args: Value,
339}
340
341enum StreamBatchPreparation {
342 Ready(Box<PreparedBatchAction>),
343 Append(Box<(StreamAppendSpec, String)>),
344 Create(Box<PreparedStreamCreate>),
345}
346
347fn batch_create_hooks(members: &[PreparedBatchMember]) -> Vec<(Uuid, String, Value)> {
348 members
349 .iter()
350 .filter_map(|member| match &member.action {
351 PreparedBatchAction::Write {
352 id,
353 kind,
354 after_create: Some(args),
355 ..
356 } => Some((*id, kind.clone(), args.clone())),
357 _ => None,
358 })
359 .collect()
360}
361
362fn place_member(error: KhiveError, member: Option<usize>) -> RuntimeResult<KhiveError> {
363 let pairs = member
364 .into_iter()
365 .map(|member| ("member".to_owned(), member.to_string()))
366 .chain(
367 error
368 .details()
369 .into_iter()
370 .flat_map(Details::iter)
371 .filter(|(key, _)| *key != "member")
372 .map(|(key, value)| (key.to_owned(), value.to_owned())),
373 );
374 let details = Details::deserialize(serde::de::value::MapDeserializer::<
375 _,
376 serde::de::value::Error,
377 >::new(pairs))
378 .map_err(|error| RuntimeError::Internal(format!("stream member details: {error}")))?;
379 Ok(error.with_details(details))
380}
381
382fn missing_write(key: &str) -> KhiveError {
383 KhiveError::not_found("note key", key).with_details(Details::new_owned([
384 ("reason", "stream_write_not_found".into()),
385 ("key", key.into()),
386 ]))
387}
388
389async fn check_observed(
390 writer: &mut dyn SqlWriter,
391 namespace: &str,
392 observed: &[StreamObservation],
393 now: Option<i64>,
394) -> Result<Option<KhiveError>, StorageError> {
395 for (index, entry) in observed.iter().enumerate() {
396 let current = crate::fence_identity::read_holder(
397 writer,
398 namespace,
399 &entry.kind,
400 &entry.key,
401 "stream-batch-observed",
402 )
403 .await?;
404 if let (Some(asserted), Some(holder)) = (entry.id, current.as_ref()) {
407 if asserted != holder.id {
408 let version = entry.version.ok_or_else(|| {
409 StorageError::Internal("identity observation missing version".into())
410 })?;
411 let mut details = vec![
412 ("reason", "identity_conflict".into()),
413 ("key", entry.key.clone()),
414 ("kind", entry.kind.clone()),
415 ("version", version.to_string()),
416 ];
417 details.extend(crate::fence_identity::identity_evidence(
418 asserted, holder.id,
419 ));
420 details.push(("index", index.to_string()));
421 return Ok(Some(
422 KhiveError::conflict("stream observation identity precondition failed")
423 .with_details(Details::new_owned(details)),
424 ));
425 }
426 }
427 let current = current.map(|holder| holder.version);
428 if current != entry.version {
429 let mut details = vec![
430 ("reason", "version_conflict".into()),
431 ("key", entry.key.clone()),
432 ("index", index.to_string()),
433 ];
434 if let Some(expected) = entry.version {
435 details.push(("expected_version", expected.to_string()));
436 }
437 if let Some(current) = current {
438 details.push(("current_version", current.to_string()));
439 }
440 return Ok(Some(
441 KhiveError::conflict("stream observation precondition failed")
442 .with_details(Details::new_owned(details)),
443 ));
444 }
445 if let Some(field) = &entry.live_until {
446 let now = now
447 .ok_or_else(|| StorageError::Internal("missing stream observation clock".into()))?;
448 if let Some(refusal) = crate::live_until::evaluate(
449 writer,
450 namespace,
451 &entry.kind,
452 &entry.key,
453 field,
454 now,
455 "stream-batch-live-until",
456 )
457 .await?
458 {
459 let mut details = vec![
460 ("reason", refusal.reason().into()),
461 ("key", entry.key.clone()),
462 ("kind", entry.kind.clone()),
463 ("version", entry.version.unwrap().to_string()),
464 ("field", field.clone()),
465 ("index", index.to_string()),
466 ];
467 details.extend(refusal.details());
468 return Ok(Some(
469 KhiveError::conflict("stream observation time precondition failed")
470 .with_details(Details::new_owned(details)),
471 ));
472 }
473 }
474 }
475 Ok(None)
476}
477
478async fn observation_clock(writer: &mut dyn SqlWriter) -> Result<i64, StorageError> {
479 crate::live_until::writer_clock(writer, "stream-batch-clock").await
480}
481
482struct BatchFailure {
483 member: Option<usize>,
484 error: RuntimeError,
485}
486
487async fn run_prepared_stream_batch(
488 access: &dyn SqlAccess,
489 namespace: String,
490 members: Vec<PreparedBatchMember>,
491 fence: Option<NoteFence>,
492 observed: Vec<StreamObservation>,
493) -> RuntimeResult<Result<(Vec<Value>, CommittedPostCommitEffects), StreamBatchRefusal>> {
494 let op: PreparedAtomicOp<Vec<Value>, BatchFailure> = Box::new(move |writer| {
495 Box::pin(async move {
496 let now = if observed.iter().any(|entry| entry.live_until.is_some()) {
498 Some(observation_clock(writer).await?)
499 } else {
500 None
501 };
502 let guard = NoteWriteGuard {
503 namespace: namespace.clone(),
504 target_id: Uuid::nil(),
505 expected_version: None,
506 fence: fence.map(Into::into),
507 create_key: None,
508 };
509 let predicate_error = if let Some(conflict) = guard.check_fence(writer).await? {
510 Some(conflict.into_error())
511 } else {
512 check_observed(writer, &namespace, &observed, now).await?
513 };
514 if let Some(error) = predicate_error {
515 return Err(PreparedAtomicError::Refused {
516 failure: BatchFailure {
517 member: None,
518 error: error.into(),
519 },
520 message: "stream batch predicate refused".into(),
521 });
522 }
523 let mut results = Vec::with_capacity(members.len());
524 let mut effects = Vec::new();
525 let mut heads = HashMap::new();
526 for member in &members {
529 if let PreparedBatchAction::Append { fence, .. } = &member.action {
530 let guard = NoteWriteGuard {
531 namespace: namespace.clone(),
532 target_id: Uuid::nil(),
533 expected_version: None,
534 fence: fence.clone(),
535 create_key: None,
536 };
537 if let Some(conflict) = guard.check_fence(writer).await? {
538 return Err(PreparedAtomicError::Refused {
539 failure: BatchFailure {
540 member: Some(member.index),
541 error: conflict.into_error().into(),
542 },
543 message: "stream append fence refused".into(),
544 });
545 }
546 }
547 }
548 for member in members {
549 let result =
550 apply_stream_member(writer, &namespace, &mut heads, member.action).await;
551 match result {
552 Ok((value, effect)) => {
553 results.push(value);
554 if let Some(effect) = effect {
555 effects.push(effect);
556 }
557 }
558 Err(error) => {
559 return Err(PreparedAtomicError::Refused {
560 failure: BatchFailure {
561 member: Some(member.index),
562 error,
563 },
564 message: "stream batch member refused".into(),
565 });
566 }
567 }
568 }
569 Ok((results, effects))
570 })
571 });
572 match run_prepared_atomic_unit(access, op).await? {
573 PreparedAtomicOutcome::Committed { value, post_commit } => Ok(Ok((value, post_commit))),
574 PreparedAtomicOutcome::RolledBack(BatchFailure {
575 member: Some(member),
576 error: RuntimeError::Khive(error),
577 }) => Ok(Err(StreamBatchRefusal {
578 member,
579 error: place_member(error, Some(member))?,
580 })),
581 PreparedAtomicOutcome::RolledBack(BatchFailure { error, .. }) => Err(error),
582 }
583}
584
585enum SequenceRefusal {
586 Exhausted,
587 Conflict { expected: i64, next: i64 },
588}
589
590fn allocate_sequence(head: &mut i64, expected: Option<i64>) -> Result<i64, SequenceRefusal> {
591 let next = head.checked_add(1).ok_or(SequenceRefusal::Exhausted)?;
592 if let Some(expected) = expected.filter(|expected| *expected != next) {
593 return Err(SequenceRefusal::Conflict { expected, next });
594 }
595 *head = next;
596 Ok(next)
597}
598
599async fn insert_stream_entry(
600 writer: &mut dyn SqlWriter,
601 namespace: &str,
602 stream: String,
603 seq: i64,
604 note_id: String,
605) -> Result<(), StorageError> {
606 writer
607 .execute(
608 SqlStatement::new(
609 "INSERT INTO note_streams(namespace,stream,seq,note_id) VALUES (?1,?2,?3,?4)",
610 vec![
611 SqlValue::Text(namespace.into()),
612 SqlValue::Text(stream),
613 SqlValue::Integer(seq),
614 SqlValue::Text(note_id),
615 ],
616 )
617 .labelled("stream"),
618 )
619 .await?;
620 Ok(())
621}
622
623async fn apply_stream_member(
624 writer: &mut dyn SqlWriter,
625 namespace: &str,
626 heads: &mut HashMap<String, i64>,
627 action: PreparedBatchAction,
628) -> RuntimeResult<(Value, Option<PostCommitEffect>)> {
629 match action {
630 PreparedBatchAction::Refused(error) => Err(error.into()),
631 PreparedBatchAction::Append {
632 stream,
633 expected_seq,
634 note,
635 plan,
636 ..
637 } => {
638 let head = match heads.entry(stream.clone()) {
639 std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
640 std::collections::hash_map::Entry::Vacant(entry) => {
641 let head = writer.query_scalar(SqlStatement::new(
642 "SELECT COALESCE(MAX(seq), 0) FROM note_streams WHERE namespace=?1 AND stream=?2",
643 vec![SqlValue::Text(namespace.into()), SqlValue::Text(stream.clone())],
644 ).labelled("stream")).await?;
645 let Some(SqlValue::Integer(head)) = head else {
646 return Err(RuntimeError::Internal("invalid stream head".into()));
647 };
648 entry.insert(head)
649 }
650 };
651 let next = allocate_sequence(head, expected_seq).map_err(|refusal| match refusal {
652 SequenceRefusal::Exhausted => {
653 RuntimeError::InvalidInput("stream sequence exhausted".into())
654 }
655 SequenceRefusal::Conflict { expected, next } => {
656 seq_conflict(&stream, expected, next, None).into()
657 }
658 })?;
659 let applied = apply_plan(writer, &plan, false).await.map_err(|error| {
660 RuntimeError::Internal(format!("stream append plan failed: {error:?}"))
661 })?;
662 insert_stream_entry(writer, namespace, stream, next, note.id.to_string()).await?;
663 Ok((
664 json!({"seq": next, "id": note.id, "created_at": micros_to_iso(note.created_at)}),
665 applied.effect,
666 ))
667 }
668 PreparedBatchAction::Write {
669 key,
670 kind,
671 id,
672 plan,
673 ..
674 } => match apply_plan(writer, &plan, false).await {
675 Ok(applied) => {
676 let stored = writer
679 .query_row(SqlStatement {
680 sql: "SELECT version, updated_at FROM notes WHERE namespace=?1 AND id=?2"
681 .into(),
682 params: vec![
683 SqlValue::Text(namespace.into()),
684 SqlValue::Text(id.to_string()),
685 ],
686 label: Some("stream-batch-write-time".into()),
687 })
688 .await?;
689 let Some(stored) = stored else {
690 return Err(RuntimeError::Internal(
691 "missing stream write timestamp".into(),
692 ));
693 };
694 Ok((
695 json!({"id": id, "version": stored.i64("version").map_err(|_| RuntimeError::Internal("stream query missing integer version".to_owned()))?, "updated_at": micros_to_iso(stored.i64("updated_at").map_err(|_| RuntimeError::Internal("stream query missing integer updated_at".to_owned()))?)}),
696 applied.effect,
697 ))
698 }
699 Err(AtomicOpFailure::NoteConflict(conflict)) => Err(conflict.into_error().into()),
700 Err(AtomicOpFailure::GuardFailed { .. }) => {
701 let holder = writer
702 .query_row(
703 khive_db::statement_lists::live_holder_read(namespace, &kind, &key)
704 .labelled("stream"),
705 )
706 .await?;
707 let Some(holder) = holder else {
708 return Err(missing_write(&key).into());
709 };
710 if holder.text("id").map_err(|_| {
713 RuntimeError::Internal("stream query missing text id".to_owned())
714 })? != id.to_string()
715 {
716 if let AtomicOpPlan::Update(update) = &plan {
717 if let Some(expected) = update
718 .note_guard
719 .as_ref()
720 .and_then(|guard| guard.expected_version)
721 {
722 return Err(NoteWriteConflict::Version {
723 expected,
724 current: holder.i64("version").map_err(|_| {
725 RuntimeError::Internal(
726 "stream query missing integer version".to_owned(),
727 )
728 })?,
729 }
730 .into_error()
731 .into());
732 }
733 }
734 }
735 Err(crate::curation::stale_note_snapshot_error(id))
736 }
737 Err(error) => Err(RuntimeError::Internal(format!(
738 "stream write plan failed: {error:?}"
739 ))),
740 },
741 }
742}
743
744impl KhiveRuntime {
745 async fn prepare_stream_appends(
749 &self,
750 token: &NamespaceToken,
751 specs: &[&StreamAppendSpec],
752 registry: &VerbRegistry,
753 ) -> RuntimeResult<Vec<PreparedAppend>> {
754 let mut fields = Vec::with_capacity(specs.len());
755 for spec in specs {
756 spec.validate_embedding(None)?;
757 validate_stream(&spec.stream)?;
758 if let Some(fences) = &spec.fence {
759 fences.validate()?;
760 for fence in fences.entries() {
761 self.validate_note_kind(&fence.kind)?;
762 }
763 }
764 let content = serde_json::to_string(&spec.record)
765 .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
766 let hook = registry.find_kind_hook(&spec.note_kind);
767 let mut args = if hook.is_some() {
771 match &spec.record {
772 Value::Object(record) => Value::Object(record.clone()),
773 _ => json!({}),
774 }
775 } else {
776 json!({})
777 };
778 args["kind"] = json!("note");
779 args["note_kind"] = json!(&spec.note_kind);
780 args["content"] = json!(content);
781 args["namespace"] = json!(token.namespace().as_str());
782 if let Some(tags) = &spec.tags {
783 args["tags"] = json!(tags);
784 }
785 if let Some(embed) = spec.embed {
786 args["embed"] = json!(embed);
787 }
788 if let Some(hook) = hook {
789 hook.prepare_create(self, &mut args).await?;
790 }
791 let mut note_fields: StreamCreateFields =
792 serde_json::from_value(args).map_err(|error| {
793 RuntimeError::InvalidInput(format!("stream append fields: {error}"))
794 })?;
795 normalize_stream_create_fields(&mut note_fields)?;
796 fields.push(note_fields);
797 }
798 let atomic_specs = specs
799 .iter()
800 .zip(&fields)
801 .map(|(spec, fields)| AtomicNoteRequest {
802 spec: AtomicNoteSpec {
803 token,
804 id: None,
805 kind: &spec.note_kind,
806 name: fields.name.as_deref(),
807 content: &fields.content,
808 properties: fields.properties.clone(),
809 },
810 options: AtomicNoteOptions {
811 salience: fields.salience,
812 decay_factor: fields.decay_factor,
813 embed: Some(fields.embed.or(spec.embed).unwrap_or(false)),
814 embedding_model: spec.embedding_model.as_deref(),
815 ..Default::default()
816 },
817 })
818 .collect();
819 let prepared = prepare_atomic_note_requests(self, atomic_specs).await?;
820 let mut out = Vec::with_capacity(specs.len());
821 for ((spec, note), plan) in specs.iter().zip(prepared.notes).zip(prepared.plans) {
822 let AtomicOpPlan::AddNote(plan) = plan else {
823 return Err(RuntimeError::Internal(
824 "stream preparation did not produce a note".into(),
825 ));
826 };
827 let plan = *plan;
828 let mut guard = plan.note_guard.ok_or_else(|| {
829 RuntimeError::Internal("stream preparation did not produce a note guard".into())
830 })?;
831 guard.fence = spec.fence.clone();
832 out.push(PreparedAppend {
833 stream: spec.stream.clone(),
834 expected_seq: spec.expected_seq,
835 note,
836 statements: plan.statements,
837 guard,
838 });
839 }
840 Ok(out)
841 }
842
843 async fn run_stream_appends(
848 access: &dyn SqlAccess,
849 token: &NamespaceToken,
850 appends: &[PreparedAppend],
851 ) -> Result<BatchOutcome, StreamAppendFailure> {
852 let ns = token.namespace().as_str().to_string();
853 let entries: Vec<_> = appends
854 .iter()
855 .map(|a| {
856 (
857 a.stream.clone(),
858 a.expected_seq,
859 a.note.id.to_string(),
860 a.statements.clone(),
861 a.guard.clone(),
862 )
863 })
864 .collect();
865 let refused_before_write = Arc::new(AtomicBool::new(false));
868 let refusal_proof = Arc::clone(&refused_before_write);
869 let op: AtomicUnitOp = Box::new(move |writer| {
870 Box::pin(async move {
871 let mut writes_started = false;
872 let result = async {
873 let mut heads: Vec<(String, i64)> = Vec::new();
874 for (stream, _, _, _, _) in &entries {
875 if heads.iter().any(|(known, _)| known == stream) {
876 continue;
877 }
878 let head = writer
879 .query_scalar(SqlStatement::new(
880 "SELECT COALESCE(MAX(seq), 0) FROM note_streams WHERE namespace=?1 AND stream=?2",
881 vec![SqlValue::Text(ns.clone()), SqlValue::Text(stream.clone())],
882 ).labelled("stream"))
883 .await?;
884 let Some(SqlValue::Integer(head)) = head else {
885 return Err(write_failure("invalid stream head"));
886 };
887 heads.push((stream.clone(), head));
888 }
889 let mut assigned = Vec::with_capacity(entries.len());
890 for (stream, expected_seq, _, _, guard) in &entries {
891 if let Some(conflict) = guard.check_fence(writer).await? {
892 return Ok(Box::new(BatchOutcome::FenceConflict { conflict })
893 as Box<dyn Any + Send>);
894 }
895 let head = heads
896 .iter_mut()
897 .find(|(known, _)| known == stream)
898 .map(|(_, head)| head)
899 .ok_or_else(|| write_failure("stream head missing"))?;
900 let next = match allocate_sequence(head, *expected_seq) {
901 Ok(next) => next,
902 Err(SequenceRefusal::Exhausted) => {
903 return Err(write_failure("stream sequence exhausted"));
904 }
905 Err(SequenceRefusal::Conflict { next, .. }) => {
906 return Ok(
907 Box::new(BatchOutcome::Conflict { next }) as Box<dyn Any + Send>
908 );
909 }
910 };
911 assigned.push(next);
912 }
913 for ((stream, _, note_id, statements, _), seq) in
914 entries.into_iter().zip(assigned.iter().copied())
915 {
916 writes_started = true;
919 for planned in statements {
920 let affected = writer.execute(planned.statement).await?;
921 if planned
922 .guard
923 .is_some_and(|guard| !guard.holds_for(affected))
924 {
925 return Err(write_failure("prepared note write guard failed"));
926 }
927 }
928 insert_stream_entry(writer, &ns, stream, seq, note_id).await?;
929 }
930 Ok(Box::new(BatchOutcome::Appended(assigned)) as Box<dyn Any + Send>)
931 }.await;
932 if result.is_err() && !writes_started {
933 refusal_proof.store(true, Ordering::Release);
934 }
935 result
936 })
937 });
938 let outcome = access
939 .atomic_unit(op)
940 .await
941 .map_err(|source| {
942 StreamAppendFailure::after_submission(
943 source.into(),
944 refused_before_write.load(Ordering::Acquire),
945 )
946 })?
947 .downcast::<BatchOutcome>()
948 .map_err(|_| {
949 StreamAppendFailure::unknown(RuntimeError::Internal(
950 "invalid stream append outcome".into(),
951 ))
952 })?;
953 Ok(*outcome)
954 }
955
956 #[allow(clippy::too_many_arguments)]
959 pub async fn stream_append(
960 &self,
961 token: &NamespaceToken,
962 stream: &str,
963 record: &Value,
964 expected_seq: Option<i64>,
965 note_kind: &str,
966 tags: Option<Vec<String>>,
967 fence: Option<NoteFences>,
968 embed: Option<bool>,
969 embedding_model: Option<String>,
970 registry: &VerbRegistry,
971 ) -> RuntimeResult<Value> {
972 self.stream_append_with_outcome(
973 token,
974 stream,
975 record,
976 expected_seq,
977 note_kind,
978 tags,
979 fence,
980 embed,
981 embedding_model,
982 registry,
983 )
984 .await
985 .map_err(StreamAppendFailure::into_source)
986 }
987
988 #[allow(clippy::too_many_arguments)]
992 pub async fn stream_append_with_outcome(
993 &self,
994 token: &NamespaceToken,
995 stream: &str,
996 record: &Value,
997 expected_seq: Option<i64>,
998 note_kind: &str,
999 tags: Option<Vec<String>>,
1000 fence: Option<NoteFences>,
1001 embed: Option<bool>,
1002 embedding_model: Option<String>,
1003 registry: &VerbRegistry,
1004 ) -> Result<Value, StreamAppendFailure> {
1005 let spec = StreamAppendSpec {
1006 stream: stream.to_string(),
1007 record: record.clone(),
1008 expected_seq,
1009 note_kind: note_kind.to_string(),
1010 tags,
1011 fence,
1012 embed,
1013 embedding_model,
1014 };
1015 let prepared = self
1016 .prepare_stream_appends(token, &[&spec], registry)
1017 .await
1018 .map_err(StreamAppendFailure::not_committed)?;
1019 let access = self.sql();
1020 match Self::run_stream_appends(access.as_ref(), token, &prepared).await? {
1021 BatchOutcome::Appended(seqs) => Ok(append_result(&prepared[0], seqs[0])),
1022 BatchOutcome::FenceConflict { conflict } => Err(StreamAppendFailure::not_committed(
1023 conflict.into_error().into(),
1024 )),
1025 BatchOutcome::Conflict { next } => Err(StreamAppendFailure::not_committed(
1026 seq_conflict(
1027 stream,
1028 expected_seq.expect("only conditional appends conflict"),
1029 next,
1030 None,
1031 )
1032 .into(),
1033 )),
1034 }
1035 }
1036
1037 fn validate_stream_batch(
1038 &self,
1039 members: &[StreamBatchMember],
1040 atomic: bool,
1041 ) -> RuntimeResult<()> {
1042 if members.is_empty() {
1043 return Err(RuntimeError::InvalidInput(
1044 "stream.batch requires at least one member".into(),
1045 ));
1046 }
1047 let mut keys = HashSet::new();
1048 for (index, member) in members.iter().enumerate() {
1049 match member {
1050 StreamBatchMember::Append(spec) => {
1051 spec.validate_embedding(atomic.then_some(index))?;
1052 validate_stream(&spec.stream)?;
1053 self.validate_note_kind(&spec.note_kind)?;
1054 if let Some(fences) = &spec.fence {
1055 fences.validate()?;
1056 for fence in fences.entries() {
1057 self.validate_note_kind(&fence.kind)?;
1058 }
1059 }
1060 crate::secret_gate::check_at(
1061 &serde_json::to_string(&spec.record)
1062 .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?,
1063 &format!("member[{index}]"),
1064 "record",
1065 )?;
1066 }
1067 StreamBatchMember::Write(spec) => {
1068 self.validate_note_kind(&spec.kind)?;
1069 NoteWriteOptions {
1070 key: Some(spec.key.clone()),
1071 expected_version: spec.expected_version,
1072 embed: spec.embed,
1073 fence: None,
1074 }
1075 .validate()?;
1076 if spec.kind == "scheduled_event" {
1077 return Err(RuntimeError::InvalidInput(
1078 "scheduled_event notes are not writable through stream.batch; use schedule verbs".into(),
1079 ));
1080 }
1081 if !keys.insert((&spec.kind, &spec.key)) {
1082 return Err(RuntimeError::InvalidInput(format!(
1083 "stream.batch repeats write key {:?} for kind {:?}",
1084 spec.key, spec.kind,
1085 )));
1086 }
1087 crate::secret_gate::check_at(
1088 &serde_json::to_string(&spec.doc)
1089 .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?,
1090 &format!("member[{index}]"),
1091 "doc",
1092 )?;
1093 if let Some(tags) = &spec.tags {
1094 crate::secret_gate::check_json_at(
1095 &json!({"tags": tags}),
1096 &format!("member[{index}]"),
1097 "tags",
1098 )?;
1099 }
1100 }
1101 StreamBatchMember::Refused(_) => {}
1102 }
1103 }
1104 Ok(())
1105 }
1106
1107 async fn prepare_stream_write(
1108 &self,
1109 token: &NamespaceToken,
1110 spec: StreamWriteSpec,
1111 registry: &VerbRegistry,
1112 ) -> RuntimeResult<StreamBatchPreparation> {
1113 let content = serde_json::to_string(&spec.doc)
1114 .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
1115 if let Some(expected) = spec.expected_version {
1116 let snapshot = match self
1117 .get_note_by_key(token, &spec.key, Some(&spec.kind), false)
1118 .await
1119 {
1120 Ok(note) => note,
1121 Err(RuntimeError::Khive(error))
1122 if error.kind() == khive_types::ErrorKind::NotFound =>
1123 {
1124 return Ok(StreamBatchPreparation::Ready(Box::new(
1125 PreparedBatchAction::Refused(missing_write(&spec.key)),
1126 )));
1127 }
1128 Err(error) => return Err(error),
1129 };
1130 let id = snapshot.id;
1131 snapshot
1132 .version
1133 .checked_add(1)
1134 .ok_or_else(|| RuntimeError::InvalidInput("note version exhausted".into()))?;
1135 let mut args = json!({
1136 "id": id, "kind": "note", "note_kind": spec.kind,
1137 "content": content, "expected_version": expected,
1138 "namespace": token.namespace().as_str(),
1139 });
1140 if let Some(tags) = spec.tags {
1141 args["properties"] = json!({"tags": tags});
1142 }
1143 if let Some(embed) = spec.embed {
1144 args["embed"] = json!(embed);
1145 }
1146 let update_policy = registry
1147 .prepare_note_update_policy(self, token, &snapshot, &mut args)
1148 .await?;
1149 let (_, plan) = crate::atomic_prepare::prepare_update_from_note_snapshot(
1150 self,
1151 token,
1152 &args,
1153 None,
1154 snapshot,
1155 update_policy,
1156 registry,
1157 )
1158 .await?;
1159 return Ok(StreamBatchPreparation::Ready(Box::new(
1160 PreparedBatchAction::Write {
1161 key: spec.key,
1162 kind: spec.kind,
1163 id,
1164 plan,
1165 after_create: None,
1166 },
1167 )));
1168 }
1169 let mut args = json!({
1170 "kind": "note", "note_kind": spec.kind, "key": spec.key,
1171 "content": content, "namespace": token.namespace().as_str(),
1172 });
1173 if let Some(tags) = spec.tags {
1174 args["tags"] = json!(tags);
1175 }
1176 if let Some(embed) = spec.embed {
1177 args["embed"] = json!(embed);
1178 }
1179 if let Some(hook) = registry.find_kind_hook(&spec.kind) {
1180 hook.prepare_create(self, &mut args).await?;
1181 }
1182 let mut fields: StreamCreateFields = serde_json::from_value(args.clone())
1183 .map_err(|error| RuntimeError::InvalidInput(format!("stream write fields: {error}")))?;
1184 normalize_stream_create_fields(&mut fields)?;
1185 let mut candidate = Note::new(token.namespace().as_str(), &spec.kind, &fields.content);
1186 candidate.name = fields.name.clone();
1187 candidate.properties = fields.properties.clone();
1188 crate::note_write::validate_head(&candidate)?;
1189 Ok(StreamBatchPreparation::Create(Box::new(
1190 PreparedStreamCreate {
1191 key: spec.key,
1192 kind: spec.kind,
1193 fields,
1194 args,
1195 },
1196 )))
1197 }
1198
1199 async fn prepare_stream_batch(
1200 &self,
1201 token: &NamespaceToken,
1202 members: Vec<StreamBatchMember>,
1203 registry: &VerbRegistry,
1204 ) -> RuntimeResult<Vec<PreparedBatchMember>> {
1205 let mut pending = Vec::with_capacity(members.len());
1206 for member in members {
1207 let action = match member {
1208 StreamBatchMember::Refused(error) => {
1209 StreamBatchPreparation::Ready(Box::new(PreparedBatchAction::Refused(error)))
1210 }
1211 StreamBatchMember::Write(spec) => {
1212 match self.prepare_stream_write(token, spec, registry).await {
1213 Ok(action) => action,
1214 Err(RuntimeError::Khive(error))
1215 if error.kind() == khive_types::ErrorKind::Conflict =>
1216 {
1217 StreamBatchPreparation::Ready(Box::new(PreparedBatchAction::Refused(
1218 error,
1219 )))
1220 }
1221 Err(error) => return Err(error),
1222 }
1223 }
1224 StreamBatchMember::Append(spec) => {
1225 let content = serde_json::to_string(&spec.record)
1226 .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
1227 StreamBatchPreparation::Append(Box::new((spec, content)))
1228 }
1229 };
1230 pending.push(action);
1231 }
1232 let requests = pending
1233 .iter()
1234 .filter_map(|action| match action {
1235 StreamBatchPreparation::Ready(_) => None,
1236 StreamBatchPreparation::Append(append) => {
1237 let (spec, content) = append.as_ref();
1238 Some(AtomicNoteRequest {
1239 spec: AtomicNoteSpec {
1240 token,
1241 id: None,
1242 kind: &spec.note_kind,
1243 name: None,
1244 content,
1245 properties: spec.tags.clone().map(|tags| json!({"tags": tags})),
1246 },
1247 options: spec.note_options(),
1248 })
1249 }
1250 StreamBatchPreparation::Create(create) => Some(AtomicNoteRequest {
1251 spec: AtomicNoteSpec {
1252 token,
1253 id: None,
1254 kind: &create.kind,
1255 name: create.fields.name.as_deref(),
1256 content: &create.fields.content,
1257 properties: create.fields.properties.clone(),
1258 },
1259 options: AtomicNoteOptions {
1260 salience: create.fields.salience,
1261 decay_factor: create.fields.decay_factor,
1262 key: Some(&create.key),
1263 embed: Some(create.fields.embed.unwrap_or(create.kind != "head")),
1264 ..Default::default()
1265 },
1266 }),
1267 })
1268 .collect();
1269 let notes = prepare_atomic_note_requests(self, requests).await?;
1270 let mut note_plans = notes.notes.into_iter().zip(notes.plans);
1271 let mut prepared = Vec::with_capacity(pending.len());
1272 for (index, pending) in pending.into_iter().enumerate() {
1273 let action = match pending {
1274 StreamBatchPreparation::Ready(action) => *action,
1275 StreamBatchPreparation::Append(append) => {
1276 let (spec, _) = *append;
1277 let (note, plan) = note_plans.next().expect("one plan per new note");
1278 PreparedBatchAction::Append {
1279 stream: spec.stream,
1280 expected_seq: spec.expected_seq,
1281 note: Box::new(note),
1282 plan,
1283 fence: spec.fence,
1284 }
1285 }
1286 StreamBatchPreparation::Create(create) => {
1287 let (note, mut plan) = note_plans.next().expect("one plan per new note");
1288 let AtomicOpPlan::AddNote(add) = &mut plan else {
1289 return Err(RuntimeError::Internal(
1290 "stream keyed create did not prepare a note".into(),
1291 ));
1292 };
1293 add.post_commit = PostCommitEffect::NoteChanged {
1294 note_id: note.id,
1295 kind: note.kind.clone(),
1296 };
1297 PreparedBatchAction::Write {
1298 key: create.key,
1299 kind: create.kind,
1300 id: note.id,
1301 plan,
1302 after_create: Some(create.args),
1303 }
1304 }
1305 };
1306 prepared.push(PreparedBatchMember { index, action });
1307 }
1308 Ok(prepared)
1309 }
1310
1311 async fn stream_batch_after_create(
1312 &self,
1313 registry: &VerbRegistry,
1314 hooks: Vec<(Uuid, String, Value)>,
1315 ) {
1316 for (id, kind, args) in hooks {
1317 if let Some(hook) = registry.find_kind_hook(&kind) {
1318 if let Err(error) = hook.after_create(self, id, &args).await {
1319 tracing::warn!(%id, %kind, %error, "stream batch after_create failed after commit");
1320 }
1321 }
1322 }
1323 }
1324
1325 pub async fn stream_batch_atomic(
1328 &self,
1329 token: &NamespaceToken,
1330 members: Vec<StreamBatchMember>,
1331 fence: Option<NoteFence>,
1332 observed: Vec<StreamObservation>,
1333 registry: &VerbRegistry,
1334 ) -> RuntimeResult<Result<Vec<Value>, StreamBatchRefusal>> {
1335 self.validate_stream_batch(&members, true)?;
1336 if let Some(fence) = &fence {
1337 fence.validate()?;
1338 self.validate_note_kind(&fence.kind)?;
1339 }
1340 for entry in &observed {
1341 if entry.id.is_some() && entry.version.is_none() {
1342 return Err(RuntimeError::InvalidInput(
1343 "observed id requires a positive version".into(),
1344 ));
1345 }
1346 if entry.live_until.is_some() && entry.version.is_none() {
1347 return Err(RuntimeError::InvalidInput(
1348 "observed live_until requires a positive version".into(),
1349 ));
1350 }
1351 crate::keyed_memory::validate_memory_key(&entry.key)?;
1352 self.validate_note_kind(&entry.kind)?;
1353 if entry.version.is_some_and(|version| version < 1) {
1354 return Err(RuntimeError::InvalidInput(
1355 "observed version must be positive or null".into(),
1356 ));
1357 }
1358 }
1359 validate_stream_batch_fence_count(stream_batch_fence_count(&members, fence.as_ref()))?;
1360 for (member, item) in members.iter().enumerate() {
1361 if let StreamBatchMember::Refused(error) = item {
1362 return Ok(Err(StreamBatchRefusal {
1363 member,
1364 error: place_member(error.clone(), Some(member))?,
1365 }));
1366 }
1367 }
1368 let prepared = self.prepare_stream_batch(token, members, registry).await?;
1369 let hooks = batch_create_hooks(&prepared);
1370 match run_prepared_stream_batch(
1371 self.sql().as_ref(),
1372 token.namespace().as_str().into(),
1373 prepared,
1374 fence,
1375 observed,
1376 )
1377 .await?
1378 {
1379 Ok((results, effects)) => {
1380 crate::atomic_prepare::apply_post_commit_effects_with_report(self, token, effects)
1381 .await?;
1382 self.stream_batch_after_create(registry, hooks).await;
1383 Ok(Ok(results))
1384 }
1385 Err(refusal) => Ok(Err(refusal)),
1386 }
1387 }
1388
1389 pub async fn stream_batch_per_member(
1394 &self,
1395 token: &NamespaceToken,
1396 members: Vec<StreamBatchMember>,
1397 registry: &VerbRegistry,
1398 ) -> RuntimeResult<Vec<Value>> {
1399 self.validate_stream_batch(&members, false)?;
1400 validate_stream_batch_fence_count(stream_batch_fence_count(&members, None))?;
1401 let mut results = Vec::with_capacity(members.len());
1402 let prepared = self.prepare_stream_batch(token, members, registry).await?;
1403 for member in prepared {
1404 if let PreparedBatchAction::Refused(error) = &member.action {
1405 results.push(refusal_value(&place_member(error.clone(), None)?)?);
1406 continue;
1407 }
1408 let hooks = batch_create_hooks(std::slice::from_ref(&member));
1409 match run_prepared_stream_batch(
1410 self.sql().as_ref(),
1411 token.namespace().as_str().into(),
1412 vec![member],
1413 None,
1414 vec![],
1415 )
1416 .await?
1417 {
1418 Ok((mut values, effects)) => {
1419 crate::atomic_prepare::apply_post_commit_effects_with_report(
1420 self, token, effects,
1421 )
1422 .await?;
1423 self.stream_batch_after_create(registry, hooks).await;
1424 results.append(&mut values);
1425 }
1426 Err(refusal) => results.push(refusal_value(&place_member(refusal.error, None)?)?),
1427 }
1428 }
1429 Ok(results)
1430 }
1431
1432 pub async fn stream_read(
1434 &self,
1435 token: &NamespaceToken,
1436 stream: &str,
1437 after: i64,
1438 limit: i64,
1439 ) -> RuntimeResult<Value> {
1440 validate_stream(stream)?;
1441 if after < 0 || limit < 1 {
1442 return Err(RuntimeError::InvalidInput(
1443 "stream.read requires after >= 0 and limit >= 1".into(),
1444 ));
1445 }
1446 let mut reader = self.sql().reader().await?;
1447 let rows = reader.query_all(SqlStatement::new(
1448 "WITH head AS (SELECT COALESCE(MAX(seq),0) AS head_seq FROM note_streams WHERE namespace=?1 AND stream=?2), \
1449 page AS (SELECT s.seq,n.id,n.content,n.created_at FROM note_streams s JOIN notes n ON n.id=s.note_id \
1450 WHERE s.namespace=?1 AND s.stream=?2 AND s.seq>?3 ORDER BY s.seq LIMIT ?4) \
1451 SELECT head.head_seq,page.seq,page.id,page.content,page.created_at FROM head LEFT JOIN page ON 1=1 ORDER BY page.seq",
1452 vec![SqlValue::Text(token.namespace().as_str().into()), SqlValue::Text(stream.into()), SqlValue::Integer(after), SqlValue::Integer(limit)],
1453 ).labelled("stream")).await?;
1454 let head = rows
1455 .first()
1456 .map(|row| {
1457 row.i64("head_seq").map_err(|_| {
1458 RuntimeError::Internal("stream query missing integer head_seq".to_owned())
1459 })
1460 })
1461 .transpose()?
1462 .unwrap_or(0);
1463 let mut entries = Vec::new();
1464 let mut last = None;
1465 for row in &rows {
1466 if matches!(row.get("seq"), Some(SqlValue::Null)) {
1467 continue;
1468 }
1469 let seq = row.i64("seq").map_err(|_| {
1470 RuntimeError::Internal("stream query missing integer seq".to_owned())
1471 })?;
1472 let record: Value = serde_json::from_str(row.text("content").map_err(|_| {
1473 RuntimeError::Internal("stream query missing text content".to_owned())
1474 })?)
1475 .map_err(|e| RuntimeError::Internal(format!("invalid stream record JSON: {e}")))?;
1476 entries.push(json!({"seq": seq, "id": row.text("id").map_err(|_| RuntimeError::Internal("stream query missing text id".to_owned()))?, "record": record, "created_at": micros_to_iso(row.i64("created_at").map_err(|_| RuntimeError::Internal("stream query missing integer created_at".to_owned()))?)}));
1477 last = Some(seq);
1478 }
1479 Ok(
1480 json!({"entries": entries, "head_seq": head, "next_after": last.filter(|last| *last < head)}),
1481 )
1482 }
1483
1484 pub async fn stream_stat(&self, token: &NamespaceToken, stream: &str) -> RuntimeResult<Value> {
1486 validate_stream(stream)?;
1487 let row = self.sql().reader().await?.query_row(SqlStatement::new(
1488 "SELECT COUNT(*) AS count, COALESCE(MAX(seq),0) AS head_seq FROM note_streams WHERE namespace=?1 AND stream=?2",
1489 vec![SqlValue::Text(token.namespace().as_str().into()), SqlValue::Text(stream.into())],
1490 ).labelled("stream")).await?.ok_or_else(|| RuntimeError::Internal("stream.stat returned no aggregate row".into()))?;
1491 Ok(
1492 json!({"head_seq": row.i64("head_seq").map_err(|_| RuntimeError::Internal("stream query missing integer head_seq".to_owned()))?, "count": row.i64("count").map_err(|_| RuntimeError::Internal("stream query missing integer count".to_owned()))?}),
1493 )
1494 }
1495
1496 pub(crate) async fn stream_member_error(
1499 &self,
1500 note: &Note,
1501 ) -> RuntimeResult<Option<RuntimeError>> {
1502 let row = self
1503 .sql()
1504 .reader()
1505 .await?
1506 .query_row(
1507 SqlStatement::new(
1508 "SELECT stream,seq FROM note_streams WHERE namespace=?1 AND note_id=?2",
1509 vec![
1510 SqlValue::Text(note.namespace.clone()),
1511 SqlValue::Text(note.id.to_string()),
1512 ],
1513 )
1514 .labelled("stream"),
1515 )
1516 .await?;
1517 row.map(|row| {
1518 Ok(KhiveError::conflict("stream entries are immutable")
1519 .with_details(Details::new_owned([
1520 ("reason", "stream_member".into()),
1521 ("id", note.id.to_string()),
1522 (
1523 "stream",
1524 row.text("stream")
1525 .map_err(|_| {
1526 RuntimeError::Internal(
1527 "stream query missing text stream".to_owned(),
1528 )
1529 })?
1530 .into(),
1531 ),
1532 (
1533 "seq",
1534 row.i64("seq")
1535 .map_err(|_| {
1536 RuntimeError::Internal(
1537 "stream query missing integer seq".to_owned(),
1538 )
1539 })?
1540 .to_string(),
1541 ),
1542 ]))
1543 .into())
1544 })
1545 .transpose()
1546 }
1547}
1548
1549#[cfg(test)]
1550#[path = "streams_batch_tests.rs"]
1551mod batch_tests;
1552
1553#[cfg(test)]
1554#[path = "streams_unit_tests.rs"]
1555mod tests;