| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "embed" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // seedFS embeds this app's own sample-document source. Both halves of |
| 11 | // each document live here: the plain-text .txt a person edits, and the |
| 12 | // matching .update.bin a real Yjs update built from it — see |
| 13 | // fixtures/generate-seed.mjs, the script that turns one into the |
| 14 | // other. Only the .bin half is ever applied to a document (Seed, |
| 15 | // below); the .txt half is embedded so a test can hold the two |
| 16 | // honest against each other (seed_test.go's |
| 17 | // TestSeedContentMatchesItsSource) rather than trusting that an |
| 18 | // edited .txt and a stale, un-regenerated .bin still agree. |
| 19 | // |
| 20 | //go:embed seeddata/*.txt seeddata/*.update.bin |
| 21 | var seedFS embed.FS |
| 22 | |
| 23 | // seedDoc describes one sample document: Base names both |
| 24 | // seeddata/<Base>.txt and seeddata/<Base>.update.bin, Title is the |
| 25 | // document's row title (Store.Create's title parameter — data, not |
| 26 | // authored UI copy, the same reasoning handlers.go's docRow.Title |
| 27 | // doc comment gives for why a document's own title is never a |
| 28 | // catalog key), and Ago is how far before Seed runs its timestamps |
| 29 | // should land. |
| 30 | type seedDoc struct { |
| 31 | Base string |
| 32 | Title string |
| 33 | Ago time.Duration |
| 34 | } |
| 35 | |
| 36 | // seedDocs is CARLOS Docs' own sample set: a project proposal, a |
| 37 | // short note, meeting notes with attendees and actions, a short piece |
| 38 | // of documentation, a letter, and one deliberately long document, per |
| 39 | // the plan's own suggested shapes. Ago values are spread across every |
| 40 | // bucket handlers.go's recencyBucket recognises (today, yesterday, |
| 41 | // this week, earlier) and, within "today", across two different |
| 42 | // times — so List's grouping has something to show rather than |
| 43 | // collapsing six documents seeded in the same second into one |
| 44 | // indistinguishable "Today" pile. |
| 45 | var seedDocs = []seedDoc{ |
| 46 | {Base: "packing-list", Title: "Packing List — Thursday Site Visit", Ago: 20 * time.Minute}, |
| 47 | {Base: "proposal", Title: "Proposal: Refitting the Elm Street Reading Room", Ago: 3 * time.Hour}, |
| 48 | {Base: "meeting-notes", Title: "Notes — Tuesday Planning Sync", Ago: 26 * time.Hour}, |
| 49 | {Base: "documentation", Title: "Quick Reference: Resetting the Site Printer", Ago: 3*24*time.Hour + 4*time.Hour}, |
| 50 | {Base: "letter", Title: "Letter to the Harbourview Allotment Committee", Ago: 9 * 24 * time.Hour}, |
| 51 | {Base: "history", Title: "A History of the Millbrook Waterworks", Ago: 52 * 24 * time.Hour}, |
| 52 | } |
| 53 | |
| 54 | // Seed populates owner's document list with CARLOS Docs' sample |
| 55 | // content: real English prose, several paragraphs each, applied as |
| 56 | // genuine Yjs updates through Store.ApplyUpdate — the same route a |
| 57 | // person's own typing takes, never a text column written directly. |
| 58 | // There is no Go-side Yjs encoder (internal/core applies updates and |
| 59 | // reads text; the ABI exposes no insert), so each update below is not |
| 60 | // built here — it is fixtures/generate-seed.mjs's committed output, |
| 61 | // exactly the route the wire-compatibility gate's own fixtures |
| 62 | // already use (ReadFixture, testing.go). |
| 63 | // |
| 64 | // It is meant for a fresh, empty owner and does not check for or skip |
| 65 | // existing documents: calling it twice against the same owner seeds |
| 66 | // the sample set twice over. That is a feature for reasoning about, |
| 67 | // not a safety net around; the safety belongs at the caller, not |
| 68 | // here. There are exactly two callers, each with its own safety |
| 69 | // story: cmd/docs/main.go's -seed flag, gated to Env == "development" |
| 70 | // (main.go's own doc comment on why); and Store.EnsurePerson's |
| 71 | // seedIfEnabled (store.go), called at most once per person, ever, on |
| 72 | // the one event in a person's history that can only happen once — |
| 73 | // their row not existing yet (seedIfEnabled's own doc comment). |
| 74 | // |
| 75 | // Takes a *Store rather than a *App: this is the same store either |
| 76 | // caller already has (app.Store(), or the store method's own s), and |
| 77 | // a *Store has no need for anything else the App wraps — the router, |
| 78 | // the mailer, the auth plugin. Store.EnsurePerson calling this |
| 79 | // directly, in-package, is what makes per-person seeding possible at |
| 80 | // all without a Store depending back on the *App that holds it. |
| 81 | // The actor is the new person themselves: seeding writes their sample |
| 82 | // library as them, so it goes through exactly the same write |
| 83 | // authorization as any other edit rather than around it. |
| 84 | func Seed(ctx context.Context, store *Store, actor Actor) error { |
| 85 | owner := actor.PersonID |
| 86 | base := time.Now().UTC() |
| 87 | |
| 88 | for _, sd := range seedDocs { |
| 89 | doc, err := store.Create(ctx, owner, sd.Title) |
| 90 | if err != nil { |
| 91 | return fmt.Errorf("docs: seed: create %q: %w", sd.Title, err) |
| 92 | } |
| 93 | |
| 94 | update, err := seedFS.ReadFile("seeddata/" + sd.Base + ".update.bin") |
| 95 | if err != nil { |
| 96 | return fmt.Errorf("docs: seed: read update for %q: %w", sd.Title, err) |
| 97 | } |
| 98 | // The assigned seq is discarded here on purpose: seeding runs |
| 99 | // when a person's row is first created, before any editor of |
| 100 | // theirs can be open, so there is nobody subscribed to notify |
| 101 | // (design D5's accepted consequence — the broadcast lives in |
| 102 | // updatesPost, not in Store). |
| 103 | if _, err := store.ApplyUpdate(ctx, doc, actor, update); err != nil { |
| 104 | return fmt.Errorf("docs: seed: apply update for %q: %w", sd.Title, err) |
| 105 | } |
| 106 | |
| 107 | // ApplyUpdate just bumped both timestamps to the real "now" — |
| 108 | // correct for a real edit, wrong for a seeded document meant |
| 109 | // to land in a specific recency bucket. Backdating both |
| 110 | // columns directly, after the fact, through the same owned() |
| 111 | // seam every other write in store.go goes through (store.go's |
| 112 | // own doc comment on owned: "the one place that can make it |
| 113 | // structurally impossible" for a write to drift off that |
| 114 | // scope) — doc.OwnerID is already owner here, so this is |
| 115 | // belt-and-braces, not a correction of anything wrong above. |
| 116 | at := base.Add(-sd.Ago) |
| 117 | if err := owned(store.db.WithContext(ctx), owner). |
| 118 | Model(&Document{}). |
| 119 | Where("id = ?", doc.ID). |
| 120 | Updates(map[string]any{"created_at": at, "updated_at": at}).Error; err != nil { |
| 121 | return fmt.Errorf("docs: seed: backdate %q: %w", sd.Title, err) |
| 122 | } |
| 123 | } |
| 124 | return nil |
| 125 | } |
| 126 | |