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