oficina / memoria Public

Native note save: a retry replays the draft, not a new note (B10)

The server minted a fresh key per request ("native-" + uuid.NewString()),
so a save that committed but whose response was lost showed as an error,
and pressing Save again stored a second copy.

The Add Note sheet now mints one UUID per draft and sends it as
client_id on every Save. The server validates it as a canonical 36-char
UUID, lowercases it, and keys the note "native-" + client_id, so
Store.Ingest's same-key-same-body path answers a replay with the first
id and stores nothing. An absent client_id keeps the old server-minted
key, so older clients still work.

Test: TestNativeNoteReplayWithSameClientIDIsANoOp. Mutations verified
red: server-minted key (replay gets a second id), raw rather than
canonical key (uppercase replay duplicates), no length check (braced and
urn: forms accepted).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 977e20c5355c22031812a4e4f22ce4ea938995c1 parent cb534c3
4 files changed, +111 −6
  • internal/memoria/native.go +28 −3
  • internal/memoria/native_test.go +74 −0
  • native/apple/Sources/MemoriaKit/AddNoteView.swift +4 −1
  • native/apple/Sources/MemoriaKit/MemoriaModel.swift +5 −2

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	0.702s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	0.004s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	0.029s
ok  	amadan.net/oficina/memoria/internal/memoria/notion	0.017s
ok  	amadan.net/oficina/memoria/internal/memoria/query	0.003s
ok  	amadan.net/oficina/memoria/internal/memoria/seal	0.003s
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	3.018s
ok  	amadan.net/oficina/memoria/internal/memoriatest	1.321s
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/memoria/native.go b/internal/memoria/native.go
index 81c2bf9..35b0bcd 100644
--- a/internal/memoria/native.go
+++ b/internal/memoria/native.go
@@ -79,11 +79,27 @@ func (a *app) nativeSearch(w http.ResponseWriter, r *http.Request) {
}
// nativeNote creates a distinct team note, even when its title matches another.
+//
+// client_id is the note's idempotency key, minted once per draft by the
+// client. Without it the server minted a fresh key per REQUEST, so a save
+// that committed but whose response was lost (timeout, a phone asleep)
+// showed as an error, and pressing Save again made a second copy (B10).
+// With it, a replay of the same draft lands on the same (source, key)
+// and Store.Ingest's same-key-same-body path answers with the first id
+// and changes nothing. A replay whose draft was edited in between revises
+// that note rather than adding another beside it, which is the point: it
+// is still one draft.
+//
+// It must be a UUID, and it is canonicalised before use, so "A1…" and
+// "a1…" are one key and a free-form string cannot choose its own key.
+// Absent, the server mints one as before, so older clients keep working
+// — and keep the old duplicate-on-retry behaviour.
func (a *app) nativeNote(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 3<<20)
var in struct {
- Title string `json:"title"`
- Body string `json:"body"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ ClientID string `json:"client_id"`
}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
@@ -91,7 +107,16 @@ func (a *app) nativeNote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Use a title and a note no larger than 2 MiB", 400)
return
}
- id, err := a.store.Ingest(r.Context(), IngestSpec{Source: "paste", Key: "native-" + uuid.NewString(), Title: strings.TrimSpace(in.Title), Body: in.Body})
+ key := uuid.NewString()
+ if in.ClientID != "" {
+ parsed, err := uuid.Parse(in.ClientID)
+ if err != nil || len(in.ClientID) != 36 {
+ http.Error(w, "The note's client id is not a UUID", 400)
+ return
+ }
+ key = parsed.String()
+ }
+ id, err := a.store.Ingest(r.Context(), IngestSpec{Source: "paste", Key: "native-" + key, Title: strings.TrimSpace(in.Title), Body: in.Body})
if err != nil && !errors.Is(err, ErrIndexLag) {
http.Error(w, "The note could not be confirmed", 500)
return
diff --git a/internal/memoria/native_test.go b/internal/memoria/native_test.go
index 2a0a628..2a5f0c3 100644
--- a/internal/memoria/native_test.go
+++ b/internal/memoria/native_test.go
@@ -208,3 +208,77 @@ func TestNativeNotesSameTitleCreatesDistinctDocuments(t *testing.T) {
t.Fatal("distinct note content was lost")
}
}
+
+// B10: a save whose response was lost is retried with the same draft's
+// client_id, and the retry must be a no-op — same id back, one document
+// stored. Mutations verified red 2026-09-23: keying on a server-minted
+// uuid again (the old behaviour) -> replay returns a second id; keying on
+// the raw client_id -> the uppercase replay makes a second note; dropping
+// the 36-character check -> braced and urn: forms are accepted.
+func TestNativeNoteReplayWithSameClientIDIsANoOp(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ post := func(payload map[string]string) (int, int64) {
+ t.Helper()
+ raw, _ := json.Marshal(payload)
+ r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/notes", strings.NewReader(string(raw)))
+ r.Header.Set("Origin", homeTestOrigin)
+ r.Header.Set("Content-Type", "application/json")
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ var got struct{ ID int64 }
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ return w.Code, got.ID
+ }
+ documents := func() int64 {
+ t.Helper()
+ var n int64
+ if err := f.app.db.G.Model(&Document{}).Count(&n).Error; err != nil {
+ t.Fatal(err)
+ }
+ return n
+ }
+
+ const draft = "6f1c2a0e-9a55-4c7e-8d3b-2f4e5a6b7c8d"
+ note := map[string]string{"title": "Standup", "body": "replayed nebula content", "client_id": draft}
+ code, first := post(note)
+ if code != 200 || first == 0 {
+ t.Fatalf("first save: %d", code)
+ }
+ before := documents()
+
+ code, again := post(note)
+ if code != 200 || again != first {
+ t.Fatalf("replay: status %d id %d, want 200 and the first id %d", code, again, first)
+ }
+ // Swift's UUID().uuidString is uppercase; the key is canonical.
+ upper := map[string]string{"title": note["title"], "body": note["body"], "client_id": strings.ToUpper(draft)}
+ if code, id := post(upper); code != 200 || id != first {
+ t.Fatalf("uppercase replay: status %d id %d, want the first id %d", code, id, first)
+ }
+ if after := documents(); after != before {
+ t.Fatalf("replay stored %d more document(s)", after-before)
+ }
+
+ // The presence half: a different draft with the same words is a new note,
+ // and so is a note from an old client that sends no client_id.
+ other := map[string]string{"title": note["title"], "body": note["body"], "client_id": "0d9e8f7a-6b5c-4d3e-9f1a-2b3c4d5e6f70"}
+ if code, id := post(other); code != 200 || id == first || id == 0 {
+ t.Fatalf("a different client_id: status %d id %d, want a new note", code, id)
+ }
+ legacy := map[string]string{"title": note["title"], "body": note["body"]}
+ _, l1 := post(legacy)
+ _, l2 := post(legacy)
+ if l1 == 0 || l2 == 0 || l1 == l2 {
+ t.Fatalf("no client_id: ids %d and %d, want two distinct notes", l1, l2)
+ }
+
+ for _, bad := range []string{"not-a-uuid", "{" + draft + "}", "urn:uuid:" + draft, strings.ReplaceAll(draft, "-", "")} {
+ if code, _ := post(map[string]string{"title": "x", "body": "y", "client_id": bad}); code != 400 {
+ t.Errorf("client_id %q: status %d, want 400", bad, code)
+ }
+ }
+}
diff --git a/native/apple/Sources/MemoriaKit/AddNoteView.swift b/native/apple/Sources/MemoriaKit/AddNoteView.swift
index fec4aad..51f145b 100644
--- a/native/apple/Sources/MemoriaKit/AddNoteView.swift
+++ b/native/apple/Sources/MemoriaKit/AddNoteView.swift
@@ -8,6 +8,9 @@ struct AddNoteView: View {
@State private var bodyText = ""
@State private var saving = false
@State private var error = ""
+ // One per sheet, reused by every Save press, so a retry after a lost response
+ // replays the same draft instead of creating a second note.
+ @State private var clientID = UUID()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
@@ -23,7 +26,7 @@ struct AddNoteView: View {
Button(saving ? "Saving…" : "Save") {
saving = true
Task {
- do { let indexed = try await model.storeNote(title: title, body: bodyText, client: client); model.query = title; if indexed { await model.search(client) } else { model.error = "Note saved. Search will find it after the server restarts." }; dismiss() }
+ do { let indexed = try await model.storeNote(title: title, body: bodyText, clientID: clientID, client: client); model.query = title; if indexed { await model.search(client) } else { model.error = "Note saved. Search will find it after the server restarts." }; dismiss() }
catch { self.error = error.localizedDescription; saving = false }
}
}.disabled(saving || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
diff --git a/native/apple/Sources/MemoriaKit/MemoriaModel.swift b/native/apple/Sources/MemoriaKit/MemoriaModel.swift
index 2113013..bc38962 100644
--- a/native/apple/Sources/MemoriaKit/MemoriaModel.swift
+++ b/native/apple/Sources/MemoriaKit/MemoriaModel.swift
@@ -70,9 +70,12 @@ struct StoredNote: Decodable { let id: Int64; let indexed: Bool }
}
#endif
}
- public func storeNote(title: String, body: String, client: NativeHTTP) async throws -> Bool {
+ /// `clientID` identifies the draft, not the request: the caller mints it once per
+ /// Add Note sheet and sends the same value on every retry, so a save whose response
+ /// was lost cannot be stored twice. The server treats a replay as a no-op.
+ public func storeNote(title: String, body: String, clientID: UUID, client: NativeHTTP) async throws -> Bool {
guard body.utf8.count <= 2 * 1024 * 1024 else { throw NativeClientError.message("Notes must be no larger than 2 MiB.") }
- let result: StoredNote = try await client.request("/api/native/notes", method: "POST", body: JSONEncoder().encode(["title": title, "body": body]))
+ let result: StoredNote = try await client.request("/api/native/notes", method: "POST", body: JSONEncoder().encode(["title": title, "body": body, "client_id": clientID.uuidString.lowercased()]))
return result.indexed
}
}