updateNotes Mutation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add updateNotes GraphQL mutation that atomically applies a batch of create/update (upsert), find-replace (patch), and hide operations without a separate commitNotes call.

Architecture: New use case internal/case/updatenotes/ follows the standard Env-interface pattern. Schema types added to schema.graphqls, code generated with make gqlgen, resolver wired in schema.resolvers.go. Patch operation reads current content from in-memory NoteViews, performs string find/replace, then calls InsertNote. Upsert delegates directly to InsertNote. Hide calls HideNotePath. All three operations in one batch share a single HandleLatestNotesAfterSave call at the end.

Tech Stack: Go, gqlgen, SQLite, sha256 (crypto/sha256 + encoding/base64), moq mocks.


File Map

Action File
Modify internal/graph/schema.graphqls
Generated (do not edit) internal/graph/generated.go, internal/graph/model/models_gen.go
Create internal/case/updatenotes/resolve.go
Create internal/case/updatenotes/resolve_test.go
Modify internal/graph/schema.resolvers.go

Task 1: Add GraphQL types and mutation to schema

Files:

  • Modify: internal/graph/schema.graphqls

Find the # hideNotes section (around line 1633) and add the new block before it. Also add the mutation to the Mutation type near pushNotes and hideNotes.

  • Step 1: Add input and payload types

Add after the # hideNotes block (find union HideNotesOrErrorPayload and insert after it):

#
# updateNotes
#

input NoteChangeUpsertInput {
  path: String!
  content: String!
  expectedHash: String
}

input NoteChangePatchInput {
  path: String!
  find: String!
  replace: String!
  expectedHash: String
}

input NoteChangeHideInput {
  path: String!
}

input NoteChangeInput {
  upsert: NoteChangeUpsertInput
  patch: NoteChangePatchInput
  hide: NoteChangeHideInput
}

input UpdateNotesInput @goExtraField(name: "ApiKey", type: "trip2g/internal/db.ApiKey") {
  changes: [NoteChangeInput!]!
}

type UpdateNotesSuccessPayload {
  paths: [String!]!
}

type UpdateNotesHashMismatchPayload {
  path: String!
  actualHash: String!
}

type UpdateNotesPatchNotFoundPayload {
  path: String!
  find: String!
}

union UpdateNotesOrErrorPayload =
    UpdateNotesSuccessPayload
  | UpdateNotesHashMismatchPayload
  | UpdateNotesPatchNotFoundPayload
  | ErrorPayload
  • Step 2: Add mutation to Mutation type

Find pushNotes(input: PushNotesInput!): PushNotesOrErrorPayload! in the type Mutation block and add directly after it:

  updateNotes(input: UpdateNotesInput!): UpdateNotesOrErrorPayload!
  • Step 3: Run gqlgen
make gqlgen

Expected: exits 0, regenerates internal/graph/generated.go and internal/graph/model/models_gen.go. New types UpdateNotesInput, NoteChangeInput, UpdateNotesSuccessPayload, UpdateNotesHashMismatchPayload, UpdateNotesPatchNotFoundPayload appear in models_gen.go. Interface MutationResolver in generated.go now includes UpdateNotes(ctx context.Context, input model.UpdateNotesInput) (model.UpdateNotesOrErrorPayload, error).

  • Step 4: Verify build fails with missing resolver
go build ./internal/graph/...

Expected: compile error — *mutationResolver does not implement MutationResolver (missing UpdateNotes method). This confirms gqlgen wired the mutation.

  • Step 5: Commit
git add internal/graph/schema.graphqls internal/graph/generated.go internal/graph/model/models_gen.go
git commit -m "feat(graphql): add updateNotes mutation schema types"

Task 2: Write failing tests for the use case

Files:

  • Create: internal/case/updatenotes/resolve_test.go

The test uses a hand-written mock (moq will be added later; write it manually here so the test compiles before implementation exists).

  • Step 1: Create test file
package updatenotes_test

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"
	"trip2g/internal/case/updatenotes"
	"trip2g/internal/db"
	"trip2g/internal/graph/model"
	appmodel "trip2g/internal/model"
)

// --- minimal mock ---

type mockEnv struct {
	notes          *appmodel.NoteViews
	insertedNotes  []appmodel.RawNote
	hiddenPaths    []string
	insertErr      error
	hideErr        error
	afterSaveErr   error
	prepareErr     error
}

func (m *mockEnv) LatestNoteViews() *appmodel.NoteViews { return m.notes }

func (m *mockEnv) InsertNote(_ context.Context, note appmodel.RawNote) (int64, error) {
	if m.insertErr != nil {
		return 0, m.insertErr
	}
	m.insertedNotes = append(m.insertedNotes, note)
	return int64(len(m.insertedNotes)), nil
}

func (m *mockEnv) HideNotePath(_ context.Context, params db.HideNotePathParams) error {
	if m.hideErr != nil {
		return m.hideErr
	}
	m.hiddenPaths = append(m.hiddenPaths, params.Value)
	return nil
}

func (m *mockEnv) HandleLatestNotesAfterSave(_ context.Context, _ []int64) error {
	return m.afterSaveErr
}

func (m *mockEnv) PrepareLatestNotes(_ context.Context, _ bool) (*appmodel.NoteViews, error) {
	if m.prepareErr != nil {
		return nil, m.prepareErr
	}
	return m.notes, nil
}

// --- helpers ---

func noteViews(path, content string) *appmodel.NoteViews {
	nv := &appmodel.NoteView{
		Path:    path,
		Content: []byte(content),
		PathID:  1,
	}
	nvs := &appmodel.NoteViews{}
	nvs.List = []*appmodel.NoteView{nv}
	return nvs
}

func ptr(s string) *string { return &s }

// --- tests ---

func TestResolve_Upsert(t *testing.T) {
	env := &mockEnv{notes: noteViews("inbox.md", "old content")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Upsert: &model.NoteChangeUpsertInput{Path: "inbox.md", Content: "new content"}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	payload, ok := result.(*model.UpdateNotesSuccessPayload)
	require.True(t, ok, "expected UpdateNotesSuccessPayload, got %T", result)
	require.Equal(t, []string{"inbox.md"}, payload.Paths)
	require.Len(t, env.insertedNotes, 1)
	require.Equal(t, "new content", env.insertedNotes[0].Content)
}

func TestResolve_Patch_Found(t *testing.T) {
	env := &mockEnv{notes: noteViews("todo.md", "- [ ] buy milk\n- [x] sleep")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Patch: &model.NoteChangePatchInput{
				Path:    "todo.md",
				Find:    "- [ ] buy milk",
				Replace: "- [x] buy milk",
			}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	payload, ok := result.(*model.UpdateNotesSuccessPayload)
	require.True(t, ok, "expected UpdateNotesSuccessPayload, got %T", result)
	require.Equal(t, []string{"todo.md"}, payload.Paths)
	require.Len(t, env.insertedNotes, 1)
	require.Equal(t, "- [x] buy milk\n- [x] sleep", env.insertedNotes[0].Content)
}

func TestResolve_Patch_NotFound(t *testing.T) {
	env := &mockEnv{notes: noteViews("todo.md", "- [ ] buy milk")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Patch: &model.NoteChangePatchInput{
				Path:    "todo.md",
				Find:    "- [ ] does not exist",
				Replace: "- [x] does not exist",
			}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	payload, ok := result.(*model.UpdateNotesPatchNotFoundPayload)
	require.True(t, ok, "expected UpdateNotesPatchNotFoundPayload, got %T", result)
	require.Equal(t, "todo.md", payload.Path)
	require.Equal(t, "- [ ] does not exist", payload.Find)
}

func TestResolve_Patch_MultipleOccurrences(t *testing.T) {
	env := &mockEnv{notes: noteViews("todo.md", "- [ ] task\n- [ ] task")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Patch: &model.NoteChangePatchInput{
				Path:    "todo.md",
				Find:    "- [ ] task",
				Replace: "- [x] task",
			}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	_, ok := result.(*model.UpdateNotesPatchNotFoundPayload)
	require.True(t, ok, "expected UpdateNotesPatchNotFoundPayload for ambiguous find, got %T", result)
}

func TestResolve_Patch_NoteNotFound(t *testing.T) {
	env := &mockEnv{notes: noteViews("other.md", "content")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Patch: &model.NoteChangePatchInput{
				Path:    "missing.md",
				Find:    "x",
				Replace: "y",
			}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	_, ok := result.(*model.ErrorPayload)
	require.True(t, ok, "expected ErrorPayload for missing note, got %T", result)
}

func TestResolve_HashMismatch_Upsert(t *testing.T) {
	env := &mockEnv{notes: noteViews("inbox.md", "actual content")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Upsert: &model.NoteChangeUpsertInput{
				Path:         "inbox.md",
				Content:      "new content",
				ExpectedHash: ptr("wronghash"),
			}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	payload, ok := result.(*model.UpdateNotesHashMismatchPayload)
	require.True(t, ok, "expected UpdateNotesHashMismatchPayload, got %T", result)
	require.Equal(t, "inbox.md", payload.Path)
	require.NotEmpty(t, payload.ActualHash)
}

func TestResolve_HashMismatch_Patch(t *testing.T) {
	env := &mockEnv{notes: noteViews("todo.md", "- [ ] task")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Patch: &model.NoteChangePatchInput{
				Path:         "todo.md",
				Find:         "- [ ] task",
				Replace:      "- [x] task",
				ExpectedHash: ptr("wronghash"),
			}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	_, ok := result.(*model.UpdateNotesHashMismatchPayload)
	require.True(t, ok, "expected UpdateNotesHashMismatchPayload, got %T", result)
}

func TestResolve_Hide(t *testing.T) {
	env := &mockEnv{notes: noteViews("old.md", "content")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Hide: &model.NoteChangeHideInput{Path: "old.md"}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	payload, ok := result.(*model.UpdateNotesSuccessPayload)
	require.True(t, ok, "expected UpdateNotesSuccessPayload, got %T", result)
	require.Equal(t, []string{"old.md"}, payload.Paths)
	require.Equal(t, []string{"old.md"}, env.hiddenPaths)
}

func TestResolve_NoOperation(t *testing.T) {
	env := &mockEnv{notes: noteViews("x.md", "y")}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{}, // no upsert/patch/hide set
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	_, ok := result.(*model.ErrorPayload)
	require.True(t, ok, "expected ErrorPayload for empty change, got %T", result)
}

func TestResolve_MixedBatch(t *testing.T) {
	nvs := &appmodel.NoteViews{}
	nvs.List = []*appmodel.NoteView{
		{Path: "todo.md", Content: []byte("- [ ] task"), PathID: 1},
		{Path: "inbox.md", Content: []byte("old"), PathID: 2},
		{Path: "gone.md", Content: []byte("bye"), PathID: 3},
	}
	env := &mockEnv{notes: nvs}
	input := model.UpdateNotesInput{
		Changes: []model.NoteChangeInput{
			{Patch: &model.NoteChangePatchInput{Path: "todo.md", Find: "- [ ] task", Replace: "- [x] task"}},
			{Upsert: &model.NoteChangeUpsertInput{Path: "inbox.md", Content: "new"}},
			{Hide: &model.NoteChangeHideInput{Path: "gone.md"}},
		},
	}
	result, err := updatenotes.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	payload, ok := result.(*model.UpdateNotesSuccessPayload)
	require.True(t, ok, "expected UpdateNotesSuccessPayload, got %T", result)
	require.ElementsMatch(t, []string{"todo.md", "inbox.md", "gone.md"}, payload.Paths)
	require.Len(t, env.insertedNotes, 2)
	require.Equal(t, []string{"gone.md"}, env.hiddenPaths)
}
  • Step 2: Run test to confirm it fails to compile (resolve.go doesn't exist yet)
go test ./internal/case/updatenotes/... 2>&1 | head -20

Expected: cannot find package "trip2g/internal/case/updatenotes" or similar compile error.


Task 3: Implement the use case

Files:

  • Create: internal/case/updatenotes/resolve.go

  • Step 1: Create resolve.go

package updatenotes

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"strings"

	"trip2g/internal/db"
	"trip2g/internal/graph/model"
	appmodel "trip2g/internal/model"
)

//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env

type Env interface {
	LatestNoteViews() *appmodel.NoteViews
	InsertNote(ctx context.Context, note appmodel.RawNote) (int64, error)
	HideNotePath(ctx context.Context, params db.HideNotePathParams) error
	PrepareLatestNotes(ctx context.Context, partial bool) (*appmodel.NoteViews, error)
	HandleLatestNotesAfterSave(ctx context.Context, pathIDs []int64) error
}

func Resolve(ctx context.Context, env Env, input model.UpdateNotesInput) (model.UpdateNotesOrErrorPayload, error) {
	if len(input.Changes) == 0 {
		return &model.UpdateNotesSuccessPayload{Paths: []string{}}, nil
	}

	var pathIDs []int64
	var paths []string

	for _, change := range input.Changes {
		opCount := 0
		if change.Upsert != nil {
			opCount++
		}
		if change.Patch != nil {
			opCount++
		}
		if change.Hide != nil {
			opCount++
		}
		if opCount != 1 {
			return &model.ErrorPayload{
				Message: "each change must have exactly one of: upsert, patch, hide",
			}, nil
		}

		switch {
		case change.Upsert != nil:
			payload, pathID, err := applyUpsert(ctx, env, change.Upsert)
			if err != nil {
				return nil, err
			}
			if payload != nil {
				return payload, nil
			}
			pathIDs = append(pathIDs, pathID)
			paths = append(paths, change.Upsert.Path)

		case change.Patch != nil:
			payload, pathID, err := applyPatch(ctx, env, change.Patch)
			if err != nil {
				return nil, err
			}
			if payload != nil {
				return payload, nil
			}
			pathIDs = append(pathIDs, pathID)
			paths = append(paths, change.Patch.Path)

		case change.Hide != nil:
			if err := applyHide(ctx, env, change.Hide, input.ApiKey.CreatedBy); err != nil {
				return nil, err
			}
			paths = append(paths, change.Hide.Path)
		}
	}

	if len(pathIDs) > 0 {
		if _, err := env.PrepareLatestNotes(ctx, false); err != nil {
			return nil, fmt.Errorf("prepare notes: %w", err)
		}
		if err := env.HandleLatestNotesAfterSave(ctx, pathIDs); err != nil {
			return nil, fmt.Errorf("handle after save: %w", err)
		}
	}

	return &model.UpdateNotesSuccessPayload{Paths: paths}, nil
}

func contentHash(content []byte) string {
	h := sha256.New()
	h.Write(content)
	return base64.URLEncoding.EncodeToString(h.Sum(nil))
}

func applyUpsert(ctx context.Context, env Env, u *model.NoteChangeUpsertInput) (model.UpdateNotesOrErrorPayload, int64, error) {
	if u.ExpectedHash != nil {
		nv := env.LatestNoteViews().GetByPath(u.Path)
		if nv != nil {
			actual := contentHash(nv.Content)
			if actual != *u.ExpectedHash {
				return &model.UpdateNotesHashMismatchPayload{Path: u.Path, ActualHash: actual}, 0, nil
			}
		}
	}

	pathID, err := env.InsertNote(ctx, appmodel.RawNote{Path: u.Path, Content: u.Content})
	if err != nil {
		return nil, 0, fmt.Errorf("insert note %s: %w", u.Path, err)
	}
	return nil, pathID, nil
}

func applyPatch(ctx context.Context, env Env, p *model.NoteChangePatchInput) (model.UpdateNotesOrErrorPayload, int64, error) {
	nv := env.LatestNoteViews().GetByPath(p.Path)
	if nv == nil {
		return &model.ErrorPayload{Message: fmt.Sprintf("note not found: %s", p.Path)}, 0, nil
	}

	if p.ExpectedHash != nil {
		actual := contentHash(nv.Content)
		if actual != *p.ExpectedHash {
			return &model.UpdateNotesHashMismatchPayload{Path: p.Path, ActualHash: actual}, 0, nil
		}
	}

	current := string(nv.Content)
	idx := strings.Index(current, p.Find)
	if idx == -1 {
		return &model.UpdateNotesPatchNotFoundPayload{Path: p.Path, Find: p.Find}, 0, nil
	}
	if strings.Index(current[idx+len(p.Find):], p.Find) != -1 {
		return &model.UpdateNotesPatchNotFoundPayload{Path: p.Path, Find: p.Find}, 0, nil
	}

	newContent := current[:idx] + p.Replace + current[idx+len(p.Find):]
	pathID, err := env.InsertNote(ctx, appmodel.RawNote{Path: p.Path, Content: newContent})
	if err != nil {
		return nil, 0, fmt.Errorf("insert note %s: %w", p.Path, err)
	}
	return nil, pathID, nil
}

func applyHide(ctx context.Context, env Env, h *model.NoteChangeHideInput, createdBy int64) error {
	return env.HideNotePath(ctx, db.HideNotePathParams{
		HiddenBy: &createdBy,
		Value:    h.Path,
	})
}
  • Step 2: Run tests
go test ./internal/case/updatenotes/... -v

Expected: all tests PASS.

  • Step 3: Commit
git add internal/case/updatenotes/
git commit -m "feat(updatenotes): implement updateNotes use case with upsert/patch/hide"

Task 4: Wire the resolver

Files:

  • Modify: internal/graph/schema.resolvers.go

  • Step 1: Add import and resolver method

Add import at the top with other case imports:

"trip2g/internal/case/updatenotes"

Find func (r *mutationResolver) HideNotes(...) and add before it:

// UpdateNotes is the resolver for the updateNotes field.
func (r *mutationResolver) UpdateNotes(ctx context.Context, input model.UpdateNotesInput) (model.UpdateNotesOrErrorPayload, error) {
	apiKey, err := checkapikey.Resolve(ctx, r.env(ctx), "update_notes")
	if err != nil {
		return nil, err
	}

	input.ApiKey = *apiKey

	return updatenotes.Resolve(ctx, r.env(ctx), input)
}
  • Step 2: Verify build
go build ./internal/graph/...

Expected: exits 0, no errors.

  • Step 3: Run all use case tests
go test ./internal/case/updatenotes/... -v

Expected: all PASS.

  • Step 4: Commit
git add internal/graph/schema.resolvers.go
git commit -m "feat(graphql): wire updateNotes resolver"

Task 5: Verify end-to-end with the dev server

  • Step 1: Start dev server
make air
  • Step 2: Test patch operation via GraphQL

Send to http://localhost:8080/graphql (with a valid API key header):

mutation {
  updateNotes(input: {
    changes: [
      {
        patch: {
          path: "some-existing-note.md"
          find: "- [ ] some task"
          replace: "- [x] some task"
        }
      }
    ]
  }) {
    ... on UpdateNotesSuccessPayload { paths }
    ... on UpdateNotesHashMismatchPayload { path actualHash }
    ... on UpdateNotesPatchNotFoundPayload { path find }
    ... on ErrorPayload { message }
  }
}

Expected: UpdateNotesSuccessPayload with the note path, and the markdown file updated.

  • Step 3: Test error cases

Send a patch with a find string that appears twice — expect UpdateNotesPatchNotFoundPayload.
Send a change with no upsert/patch/hide — expect ErrorPayload.

  • Step 4: Stop dev server, commit if any fixes were needed