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>
4 files changed,
+111
−6
internal/memoria/native.go+28 −3internal/memoria/native_test.go+74 −0native/apple/Sources/MemoriaKit/AddNoteView.swift+4 −1native/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 |
| } |
| } |