Seed sample documents per person, on person creation, not per instance
Documents are owner-scoped and a boot-time seed has no person to own anything, so seeding moves from an instance-wide, boot-time event (the -seed flag, still gated to -env development for local dev) to a per-person one, hooked into Store.EnsurePerson's create branch only. Tying it to "this Person row was just created" rather than "this person's document list is empty" matters: a person who deletes every sample document keeps their Person row, so their next sign-in takes EnsurePerson's found branch and gets nothing resurrected. Opt-in via $DOCS_SAMPLE_DOCUMENTS (default off, parsed permissively rather than fail-closed — this only gates cosmetic content, not who can sign in). Seeding runs after the person row's INSERT has already committed, not inside any transaction around it: it calls into WebAssembly per document and must never hold locks against, or roll back, the person's own row. A seeding failure is logged and swallowed there, never surfaced as an EnsurePerson error — a person who can't sign in because sample content failed to generate is worse than one with an empty list. EnsurePerson's existing duplicate-key race handling is unchanged, so only one concurrent first sign-in ever seeds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
7 files changed,
+580
−21
cmd/docs/main.go+26 −5internal/docs/app.go+55 −1internal/docs/sample_documents_internal_test.go+203 −0internal/docs/sample_documents_test.go+189 −0internal/docs/seed.go+18 −9internal/docs/seed_test.go+3 −3internal/docs/store.go+86 −3
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| index 3780808..7656c6a 100644 |
| --- a/cmd/docs/main.go |
| +++ b/cmd/docs/main.go |
| @@ -229,6 +229,17 @@ func main() { |
| // would otherwise silently mean. |
| allowedAddresses := os.Getenv("DOCS_ALLOWED_ADDRESSES") |
| + // $DOCS_SAMPLE_DOCUMENTS opts a deployed instance into |
| + // docs.Config.SampleDocuments — see its own doc comment for the |
| + // full reasoning (parsed permissively, default off, deliberately |
| + // not fail-closed like the settings just above). This is the |
| + // platform-reachable fix for the bug this branch exists to close: |
| + // -seed below never runs on a deployed instance (gated to -env |
| + // development, which activation argv never sets), so without this |
| + // env var a real deployment's sample content stays exactly as |
| + // unreachable as it was before. |
| + sampleDocuments := os.Getenv("DOCS_SAMPLE_DOCUMENTS") |
| + |
| // chooseEnv: -env wins; $DOCS_ENV wins next EXCEPT for |
| // "development" itself, which chooseEnv never honours from an env |
| // var (fix round 2 — see its own doc comment); the default with |
| @@ -258,6 +269,7 @@ func main() { |
| InstanceKey: instanceKey, |
| Mailer: mailer, |
| AllowedAddresses: allowedAddresses, |
| + SampleDocuments: sampleDocuments, |
| Logger: logger, |
| } |
| app, err := docs.New(cfg) |
| @@ -268,21 +280,30 @@ func main() { |
| defer app.Close() |
| // Only reachable with both -seed and -env development (the check |
| - // above) — after New, since docs.Seed needs a real *docs.App with |
| - // its store and migrated database already in place, and after the |
| - // InstanceKey/Mailer checks above, so a database that cannot even |
| - // send a real sign-in link never gets seeded either. |
| + // above) — after New, since both EnsurePerson and docs.Seed need a |
| + // real app.Store() over an already-migrated database, and after |
| + // the InstanceKey/Mailer checks above, so a database that cannot |
| + // even send a real sign-in link never gets seeded either. |
| if seedFlag { |
| seedAddress := strings.TrimSpace(os.Getenv("DOCS_SEED_ADDRESS")) |
| if seedAddress == "" { |
| seedAddress = devSeedAddress |
| } |
| + // EnsurePerson here runs with cfg.SampleDocuments as given on |
| + // this invocation: if it's also truthy, EnsurePerson's own |
| + // per-person seeding (store.go) already seeds seedAddress the |
| + // moment its Person row is first created, before this call |
| + // even returns — see its doc comment. The explicit docs.Seed |
| + // call just below runs unconditionally regardless, exactly as |
| + // it always has: -seed's whole point is "seed this address |
| + // right now, guaranteed", not "seed it only if some other |
| + // setting happens to agree." |
| owner, err := app.Store().EnsurePerson(context.Background(), seedAddress) |
| if err != nil { |
| logger.Error("docs: seed: resolve person", "address", seedAddress, "err", err) |
| os.Exit(1) |
| } |
| - if err := docs.Seed(context.Background(), app, owner); err != nil { |
| + if err := docs.Seed(context.Background(), app.Store(), owner); err != nil { |
| logger.Error("docs: seed", "address", seedAddress, "err", err) |
| os.Exit(1) |
| } |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 5be3dfe..eb2b6da 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -160,10 +160,64 @@ type Config struct { |
| // alternative of silently admitting nobody. |
| AllowedAddresses string |
| + // SampleDocuments opts this instance into seeding a brand-new |
| + // person's very first sign-in with CARLOS Docs' own sample |
| + // documents (seed.go's Seed, six of them) — the fix for "Paul |
| + // signed in to a live instance and saw an empty list": the sample |
| + // documents docs.Seed already wrote were never reachable on a |
| + // deployed instance, because -seed is gated to Env == |
| + // "development" (main.go's own doc comment, unchanged by this |
| + // field) and a deployed instance never runs with that flag. |
| + // Documents are owner-scoped (Document.OwnerID, owned() in |
| + // store.go) and a boot-time seed has no person to own anything, so |
| + // this switches seeding from a boot-time, instance-wide event to a |
| + // per-person one — see Store.EnsurePerson's own doc comment for |
| + // exactly where that happens and why there, not "the list is |
| + // empty" (a person who deletes every sample document must not get |
| + // them back). |
| + // |
| + // Sourced from $DOCS_SAMPLE_DOCUMENTS (cmd/docs/main.go), the same |
| + // "<APP>_..." convention every other setting on this struct uses. |
| + // Parsed by parseSampleDocuments below, which accepts a small, |
| + // case-insensitive truthy set ("1", "true", "yes") and treats |
| + // everything else — unset, empty, or a typo alike — as off. |
| + // Default OFF: a real team instance must never get six invented |
| + // documents about a reading room and an allotment committee just |
| + // because nobody thought to unset something. |
| + // |
| + // Deliberately NOT fail-closed the way Origin/InstanceKey/ |
| + // AllowedAddresses above are: those three guard who can sign in |
| + // and what seals a session, where a value New cannot make sense of |
| + // silently taking the permissive branch would be a real security |
| + // regression, so New refuses to start rather than guess. This |
| + // setting only decides whether a new person's document list starts |
| + // with six sample documents or zero — cosmetic content, not a |
| + // security control — so an unparseable value here is read as "off" |
| + // and New boots anyway. A platform whose env var block has a typo |
| + // in $DOCS_SAMPLE_DOCUMENTS should get an empty list, silently, the |
| + // same outcome as never having set it at all, not a refusal to |
| + // serve any traffic over a setting that was never load-bearing for |
| + // safety in the first place. |
| + SampleDocuments string |
| + |
| // Logger defaults to slog.Default(). |
| Logger *slog.Logger |
| } |
| +// parseSampleDocuments interprets raw the way Config.SampleDocuments' |
| +// own doc comment describes: a small, case-insensitive truthy set |
| +// means on, and everything else means off. See that doc comment for |
| +// why this is deliberately not fail-closed like resolveInstanceKey/ |
| +// resolveAllowlist (auth.go) just below use for their own settings. |
| +func parseSampleDocuments(raw string) bool { |
| + switch strings.ToLower(strings.TrimSpace(raw)) { |
| + case "1", "true", "yes": |
| + return true |
| + default: |
| + return false |
| + } |
| +} |
| + |
| // DefaultOrigin is Config.Origin's fallback when empty — see its doc |
| // comment. Exported so a caller that needs to know what origin this |
| // app is protecting itself against (NewForTest's callers, notably) |
| @@ -323,7 +377,7 @@ func New(cfg Config) (*App, error) { |
| d.Close() |
| return nil, fmt.Errorf("docs: start core runtime: %w", err) |
| } |
| - store := NewStore(d.G, rt) |
| + store := NewStore(d.G, rt, parseSampleDocuments(cfg.SampleDocuments), logger) |
| render, err := newRenderer() |
| if err != nil { |
diff --git a/internal/docs/sample_documents_internal_test.go b/internal/docs/sample_documents_internal_test.go| new file mode 100644 |
| index 0000000..eaa0b9d |
| --- /dev/null |
| +++ b/internal/docs/sample_documents_internal_test.go |
| @@ -0,0 +1,203 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/docs/internal/core" |
| +) |
| + |
| +// newSampleDocumentsInternalTestApp is this file's own copy of |
| +// sample_documents_test.go's newSampleDocumentsTestApp: that one |
| +// lives in package docs_test and cannot be reused from here, and the |
| +// tests below need package-internal access (a *Store's own db handle, |
| +// to soft-delete rows the way a future "delete document" handler |
| +// would; a second, deliberately broken *core.Runtime) that only a |
| +// docs-package test can reach. |
| +func newSampleDocumentsInternalTestApp(t *testing.T, sampleDocuments string) *App { |
| + t.Helper() |
| + app, err := New(Config{ |
| + DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| + Env: "development", |
| + SampleDocuments: sampleDocuments, |
| + }) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + t.Cleanup(func() { |
| + if cerr := app.Close(); cerr != nil { |
| + t.Errorf("App.Close: %v", cerr) |
| + } |
| + }) |
| + return app |
| +} |
| + |
| +// TestPersonWhoDeletesSampleDocumentsGetsNoneBackOnNextSignIn is the |
| +// property EnsurePerson's own doc comment calls out by name: seeding |
| +// is tied to "this Person row was just created", never to "this |
| +// person's list happens to be empty". There is no delete handler yet |
| +// (spec's later work), so this reaches into store.db directly to |
| +// soft-delete every one of the seeded rows exactly the way such a |
| +// handler would (setting deleted_at, the same column List's own |
| +// "deleted_at IS NULL" filter checks) — then signs the same address |
| +// in again and asserts nothing comes back. |
| +func TestPersonWhoDeletesSampleDocumentsGetsNoneBackOnNextSignIn(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "1") |
| + ctx := context.Background() |
| + store := app.Store() |
| + |
| + owner, err := store.EnsurePerson(ctx, "deletes-everything@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson (first): %v", err) |
| + } |
| + first, err := store.List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List (first): %v", err) |
| + } |
| + if len(first) != 6 { |
| + t.Fatalf("got %d seeded documents, want 6", len(first)) |
| + } |
| + |
| + now := time.Now().UTC() |
| + if err := owned(store.db.WithContext(ctx), owner). |
| + Model(&Document{}). |
| + Update("deleted_at", now).Error; err != nil { |
| + t.Fatalf("soft-delete owner's documents: %v", err) |
| + } |
| + |
| + afterDelete, err := store.List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List (after delete): %v", err) |
| + } |
| + if len(afterDelete) != 0 { |
| + t.Fatalf("got %d documents after deleting all of them, want 0", len(afterDelete)) |
| + } |
| + |
| + // The person row already exists, so this hits EnsurePerson's |
| + // found branch, not its create branch — seedIfEnabled is never |
| + // called, and List must stay at zero. |
| + ownerAgain, err := store.EnsurePerson(ctx, "deletes-everything@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson (second): %v", err) |
| + } |
| + if ownerAgain != owner { |
| + t.Fatalf("EnsurePerson(again) = %d, want %d", ownerAgain, owner) |
| + } |
| + |
| + after, err := store.List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List (after second sign-in): %v", err) |
| + } |
| + if len(after) != 0 { |
| + t.Fatalf("got %d documents back after signing in again, want 0 — sample documents were resurrected", len(after)) |
| + } |
| +} |
| + |
| +// TestEnsurePersonSurvivesSeedingFailure is the care point the task |
| +// calls out explicitly: seeding failure must never break sign-in. To |
| +// force Seed itself to fail without touching the Person row |
| +// EnsurePerson creates first (which would defeat the point of the |
| +// test — the person must exist BEFORE seeding is even attempted), |
| +// this builds a second, deliberately broken *core.Runtime — closed |
| +// immediately after it compiles — and hands it to a Store sharing the |
| +// real, working database underneath app's own Store. |
| +// |
| +// Confirmed directly (see the probe this test's design was checked |
| +// against): calling a wazero module method through a Runtime whose |
| +// underlying wazero runtime has been Close'd returns a clean error |
| +// ("runtime closed with exit_code(0)"), not a panic — Store.replay's |
| +// s.core.NewDoc call, the first thing Seed's loop does for its first |
| +// document's ApplyUpdate, fails this way every time, deterministically, |
| +// with no flakiness from timing. |
| +func TestEnsurePersonSurvivesSeedingFailure(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "1") |
| + ctx := context.Background() |
| + |
| + brokenCore, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + if err := brokenCore.Close(ctx); err != nil { |
| + t.Fatalf("brokenCore.Close: %v", err) |
| + } |
| + |
| + broken := NewStore(app.store.db, brokenCore, true, app.logger) |
| + |
| + id, err := broken.EnsurePerson(ctx, "seed-fails@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson must succeed even when seeding fails, got: %v", err) |
| + } |
| + if id <= 0 { |
| + t.Fatalf("EnsurePerson returned a non-positive id: %d", id) |
| + } |
| + |
| + // The person is durably there: a fresh EnsurePerson call for the |
| + // same address finds the same row rather than creating (or |
| + // re-seeding) it. |
| + id2, err := broken.EnsurePerson(ctx, "seed-fails@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson (again): %v", err) |
| + } |
| + if id2 != id { |
| + t.Fatalf("EnsurePerson(again) = %d, want %d (same person, not recreated)", id2, id) |
| + } |
| + |
| + // And the same person, looked up through the app's own working |
| + // Store, is a real, usable person: List works and returns without |
| + // error (whatever partial content Seed managed before it failed — |
| + // this asserts "does not break", not "produced zero documents", |
| + // since Seed's per-document loop may have left one partial row |
| + // behind before its first ApplyUpdate failed). |
| + if _, err := app.Store().List(ctx, id); err != nil { |
| + t.Fatalf("List for the person seeding failed for: %v", err) |
| + } |
| +} |
| + |
| +// TestConcurrentFirstSignInsProduceOnePersonAndOneSampleSet is the |
| +// concurrency requirement stated explicitly: two simultaneous first |
| +// sign-ins for the same brand-new address must not produce two |
| +// people or two sets of documents. EnsurePerson's own duplicate-key |
| +// race handling (store.go) already guarantees one Person row; this |
| +// pins that seeding rides along with it — exactly one winner reaches |
| +// seedIfEnabled, so the address ends up with exactly six documents, |
| +// never twelve. |
| +func TestConcurrentFirstSignInsProduceOnePersonAndOneSampleSet(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "1") |
| + store := app.Store() |
| + const address = "concurrent-first-signin@example.com" |
| + |
| + const n = 8 |
| + ids := make([]int64, n) |
| + errs := make([]error, n) |
| + var wg sync.WaitGroup |
| + wg.Add(n) |
| + for i := 0; i < n; i++ { |
| + go func(i int) { |
| + defer wg.Done() |
| + ids[i], errs[i] = store.EnsurePerson(context.Background(), address) |
| + }(i) |
| + } |
| + wg.Wait() |
| + |
| + for i, err := range errs { |
| + if err != nil { |
| + t.Fatalf("EnsurePerson[%d]: %v", i, err) |
| + } |
| + } |
| + for i := 1; i < n; i++ { |
| + if ids[i] != ids[0] { |
| + t.Fatalf("EnsurePerson returned different ids across concurrent calls: %d and %d — two people were created for one address", ids[0], ids[i]) |
| + } |
| + } |
| + |
| + list, err := store.List(context.Background(), ids[0]) |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + if len(list) != 6 { |
| + t.Fatalf("got %d documents after %d concurrent first sign-ins, want exactly 6 (one seeded set, not %d)", len(list), n, n*6) |
| + } |
| +} |
diff --git a/internal/docs/sample_documents_test.go b/internal/docs/sample_documents_test.go| new file mode 100644 |
| index 0000000..72f10fd |
| --- /dev/null |
| +++ b/internal/docs/sample_documents_test.go |
| @@ -0,0 +1,189 @@ |
| +package docs_test |
| + |
| +import ( |
| + "context" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// newSampleDocumentsTestApp builds a development App exactly like |
| +// docs.NewForTest, except with Config.SampleDocuments set explicitly |
| +// — the same "build directly via New, package-internal test helper" |
| +// shape auth_allowlist_test.go's newAllowlistedTestApp uses for the |
| +// same reason: NewForTest itself never sets this, so any test that |
| +// wants seeding on (or explicitly off) has to build its own App. |
| +func newSampleDocumentsTestApp(t *testing.T, sampleDocuments string) *docs.App { |
| + t.Helper() |
| + app, err := docs.New(docs.Config{ |
| + DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| + Env: "development", |
| + SampleDocuments: sampleDocuments, |
| + }) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + t.Cleanup(func() { |
| + if cerr := app.Close(); cerr != nil { |
| + t.Errorf("App.Close: %v", cerr) |
| + } |
| + }) |
| + return app |
| +} |
| + |
| +// TestNewPersonWithSampleDocumentsOnGetsExactlySix pins the design |
| +// this branch exists to ship: a brand-new person's very first |
| +// EnsurePerson call, on an instance that opted in via |
| +// $DOCS_SAMPLE_DOCUMENTS, gets seed.go's own sample set — exactly six |
| +// documents (len(seedDocs) in seed.go), owned by them, each with real, |
| +// substantial text read back through the actual CRDT (Store.Text), |
| +// never a row count standing in for content that was never applied. |
| +func TestNewPersonWithSampleDocumentsOnGetsExactlySix(t *testing.T) { |
| + app := newSampleDocumentsTestApp(t, "1") |
| + ctx := context.Background() |
| + |
| + owner, err := app.Store().EnsurePerson(ctx, "brand-new@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson: %v", err) |
| + } |
| + |
| + list, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + if len(list) != 6 { |
| + t.Fatalf("got %d documents, want exactly 6", len(list)) |
| + } |
| + |
| + for _, d := range list { |
| + text, err := app.Store().Text(ctx, d) |
| + if err != nil { |
| + t.Fatalf("Text(%q): %v", d.Title, err) |
| + } |
| + if len(strings.TrimSpace(text)) < 200 { |
| + t.Errorf("%q has %d characters of text — want substantial, real content", d.Title, len(text)) |
| + } |
| + if d.OwnerID != owner { |
| + t.Errorf("%q has OwnerID %d, want %d", d.Title, d.OwnerID, owner) |
| + } |
| + } |
| +} |
| + |
| +// TestNewPersonWithSampleDocumentsOffGetsNone is the opt-in's default: |
| +// with $DOCS_SAMPLE_DOCUMENTS unset (SampleDocuments left "" here, |
| +// exactly what a real deployment gets if nobody has set the variable |
| +// at all), a brand-new person's list starts empty — the pre-existing, |
| +// unchanged behaviour every real team instance must keep. |
| +func TestNewPersonWithSampleDocumentsOffGetsNone(t *testing.T) { |
| + app := newSampleDocumentsTestApp(t, "") |
| + ctx := context.Background() |
| + |
| + owner, err := app.Store().EnsurePerson(ctx, "brand-new@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson: %v", err) |
| + } |
| + |
| + list, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + if len(list) != 0 { |
| + t.Fatalf("got %d documents with SampleDocuments off, want 0", len(list)) |
| + } |
| +} |
| + |
| +// TestExistingPersonSigningInAgainGetsNoNewDocuments is the seam this |
| +// whole design hangs off: EnsurePerson's create branch seeds, its |
| +// found branch (Person row already exists) never does. Calling |
| +// EnsurePerson a second time for the same address must not grow the |
| +// document count at all, whether or not sample content already |
| +// landed on the first call. |
| +func TestExistingPersonSigningInAgainGetsNoNewDocuments(t *testing.T) { |
| + app := newSampleDocumentsTestApp(t, "true") |
| + ctx := context.Background() |
| + |
| + owner, err := app.Store().EnsurePerson(ctx, "returning@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson (first): %v", err) |
| + } |
| + first, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List (first): %v", err) |
| + } |
| + if len(first) != 6 { |
| + t.Fatalf("got %d documents after first sign-in, want 6", len(first)) |
| + } |
| + |
| + ownerAgain, err := app.Store().EnsurePerson(ctx, "returning@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson (second): %v", err) |
| + } |
| + if ownerAgain != owner { |
| + t.Fatalf("EnsurePerson(again) = %d, want the same person id %d", ownerAgain, owner) |
| + } |
| + |
| + second, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List (second): %v", err) |
| + } |
| + if len(second) != len(first) { |
| + t.Fatalf("document count grew from %d to %d on a repeat sign-in", len(first), len(second)) |
| + } |
| +} |
| + |
| +// TestTwoPeopleEachGetTheirOwnSampleSet checks seeding does not |
| +// undermine owned() (store.go): two different brand-new people each |
| +// get their own full set of six, and List for one never includes a |
| +// row belonging to the other — the same ownership guarantee |
| +// TestOwnedScopesByOwner (scope_test.go) pins for hand-created |
| +// documents, exercised here through the seeding path instead. |
| +func TestTwoPeopleEachGetTheirOwnSampleSet(t *testing.T) { |
| + app := newSampleDocumentsTestApp(t, "yes") |
| + ctx := context.Background() |
| + |
| + alice, err := app.Store().EnsurePerson(ctx, "alice-sample@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(alice): %v", err) |
| + } |
| + bob, err := app.Store().EnsurePerson(ctx, "bob-sample@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(bob): %v", err) |
| + } |
| + if alice == bob { |
| + t.Fatalf("alice and bob resolved to the same person id %d", alice) |
| + } |
| + |
| + aliceDocs, err := app.Store().List(ctx, alice) |
| + if err != nil { |
| + t.Fatalf("List(alice): %v", err) |
| + } |
| + bobDocs, err := app.Store().List(ctx, bob) |
| + if err != nil { |
| + t.Fatalf("List(bob): %v", err) |
| + } |
| + if len(aliceDocs) != 6 { |
| + t.Fatalf("alice has %d documents, want 6", len(aliceDocs)) |
| + } |
| + if len(bobDocs) != 6 { |
| + t.Fatalf("bob has %d documents, want 6", len(bobDocs)) |
| + } |
| + |
| + for _, d := range aliceDocs { |
| + if d.OwnerID != alice { |
| + t.Errorf("alice's List returned a document owned by %d, not %d", d.OwnerID, alice) |
| + } |
| + if _, err := app.Store().Get(ctx, bob, d.Sqid); err == nil { |
| + t.Errorf("bob can Get alice's document %q — owned() is not isolating seeded documents", d.Sqid) |
| + } |
| + } |
| + for _, d := range bobDocs { |
| + if d.OwnerID != bob { |
| + t.Errorf("bob's List returned a document owned by %d, not %d", d.OwnerID, bob) |
| + } |
| + if _, err := app.Store().Get(ctx, alice, d.Sqid); err == nil { |
| + t.Errorf("alice can Get bob's document %q — owned() is not isolating seeded documents", d.Sqid) |
| + } |
| + } |
| +} |
diff --git a/internal/docs/seed.go b/internal/docs/seed.go| index fd608af..7a4dbc4 100644 |
| --- a/internal/docs/seed.go |
| +++ b/internal/docs/seed.go |
| @@ -61,15 +61,24 @@ var seedDocs = []seedDoc{ |
| // exactly the route the wire-compatibility gate's own fixtures |
| // already use (ReadFixture, testing.go). |
| // |
| -// It is meant for a fresh, empty database — cmd/docs/main.go's -seed |
| -// flag, gated to Env == "development", is the only caller — and does |
| -// not check for or skip existing documents: calling it twice against |
| -// the same owner seeds the sample set twice over. That is a feature |
| -// for reasoning about, not a safety net around; the safety belongs at |
| -// the caller, not here (main.go's own doc comment on why -seed is |
| -// gated the way it is). |
| -func Seed(ctx context.Context, app *App, owner int64) error { |
| - store := app.Store() |
| +// It is meant for a fresh, empty owner and does not check for or skip |
| +// existing documents: calling it twice against the same owner seeds |
| +// the sample set twice over. That is a feature for reasoning about, |
| +// not a safety net around; the safety belongs at the caller, not |
| +// here. There are exactly two callers, each with its own safety |
| +// story: cmd/docs/main.go's -seed flag, gated to Env == "development" |
| +// (main.go's own doc comment on why); and Store.EnsurePerson's |
| +// seedIfEnabled (store.go), called at most once per person, ever, on |
| +// the one event in a person's history that can only happen once — |
| +// their row not existing yet (seedIfEnabled's own doc comment). |
| +// |
| +// Takes a *Store rather than a *App: this is the same store either |
| +// caller already has (app.Store(), or the store method's own s), and |
| +// a *Store has no need for anything else the App wraps — the router, |
| +// the mailer, the auth plugin. Store.EnsurePerson calling this |
| +// directly, in-package, is what makes per-person seeding possible at |
| +// all without a Store depending back on the *App that holds it. |
| +func Seed(ctx context.Context, store *Store, owner int64) error { |
| base := time.Now().UTC() |
| for _, sd := range seedDocs { |
diff --git a/internal/docs/seed_test.go b/internal/docs/seed_test.go| index 5c84f04..1c11da6 100644 |
| --- a/internal/docs/seed_test.go |
| +++ b/internal/docs/seed_test.go |
| @@ -17,7 +17,7 @@ func TestSeedProducesReadableDocuments(t *testing.T) { |
| ctx := context.Background() |
| owner := app.TestPerson(t, "seed@example.com") |
| - if err := docs.Seed(ctx, app, owner); err != nil { |
| + if err := docs.Seed(ctx, app.Store(), owner); err != nil { |
| t.Fatalf("Seed: %v", err) |
| } |
| @@ -54,7 +54,7 @@ func TestSeedVariesTimestamps(t *testing.T) { |
| ctx := context.Background() |
| owner := app.TestPerson(t, "seed@example.com") |
| - if err := docs.Seed(ctx, app, owner); err != nil { |
| + if err := docs.Seed(ctx, app.Store(), owner); err != nil { |
| t.Fatalf("Seed: %v", err) |
| } |
| @@ -86,7 +86,7 @@ func TestSeedContentMatchesItsSource(t *testing.T) { |
| ctx := context.Background() |
| owner := app.TestPerson(t, "source-check@example.com") |
| - if err := docs.Seed(ctx, app, owner); err != nil { |
| + if err := docs.Seed(ctx, app.Store(), owner); err != nil { |
| t.Fatalf("Seed: %v", err) |
| } |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index fde79d1..a4a3cff 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -5,6 +5,7 @@ import ( |
| crand "crypto/rand" |
| "errors" |
| "fmt" |
| + "log/slog" |
| mrand "math/rand/v2" |
| "strings" |
| "time" |
| @@ -21,6 +22,17 @@ import ( |
| type Store struct { |
| db *gorm.DB |
| core *core.Runtime |
| + |
| + // sampleDocuments mirrors Config.SampleDocuments, already parsed — |
| + // see EnsurePerson's own doc comment for what it gates and why the |
| + // gate lives here rather than at the HTTP layer. |
| + sampleDocuments bool |
| + |
| + // logger is where a sample-seeding failure goes — see |
| + // seedIfEnabled's own doc comment for why that failure is logged |
| + // and swallowed here rather than ever returned to EnsurePerson's |
| + // caller. |
| + logger *slog.Logger |
| } |
| // NewStore builds a Store over an already-migrated *gorm.DB and a |
| @@ -28,8 +40,20 @@ type Store struct { |
| // and is reused for every document (core.New's doc comment); each |
| // call into it below opens its own per-document instance (a |
| // *core.Handle) and closes it again. |
| -func NewStore(db *gorm.DB, rt *core.Runtime) *Store { |
| - return &Store{db: db, core: rt} |
| +// |
| +// sampleDocuments and logger back EnsurePerson's own per-person |
| +// sample seeding (see its doc comment): sampleDocuments is |
| +// Config.SampleDocuments already parsed by parseSampleDocuments |
| +// (app.go), and logger is where a seeding failure is reported rather |
| +// than propagated. A nil logger defaults to slog.Default(), the same |
| +// fallback New (app.go) applies to Config.Logger — this Store can be |
| +// built directly in a test (scope_test.go and others already do) with |
| +// no logger of its own to pass. |
| +func NewStore(db *gorm.DB, rt *core.Runtime, sampleDocuments bool, logger *slog.Logger) *Store { |
| + if logger == nil { |
| + logger = slog.Default() |
| + } |
| + return &Store{db: db, core: rt, sampleDocuments: sampleDocuments, logger: logger} |
| } |
| // sqidAlphabet avoids visually ambiguous characters (0/O, 1/l/I) — |
| @@ -132,6 +156,17 @@ func owned(g *gorm.DB, owner int64) *gorm.DB { |
| // a test can mint an owner id without going through HTTP at all — the |
| // two must and do agree, since TestPerson is this method and nothing |
| // else. |
| +// |
| +// The create branch below — and only the create branch — is also |
| +// where a brand-new person's sample documents get seeded |
| +// (seedIfEnabled), when this instance has opted in. That is |
| +// deliberate and is the whole reason this hangs off "a Person row was |
| +// just created" rather than off "List came back empty": a person who |
| +// deletes every one of their documents still has a Person row, so |
| +// their next sign-in takes the found branch just above and returns |
| +// early — never reaching seedIfEnabled — and gets nothing back. |
| +// Seeding is a one-time event in a person's history, tied to the one |
| +// moment that can only ever happen once: their row not existing yet. |
| func (s *Store) EnsurePerson(ctx context.Context, email string) (int64, error) { |
| email = strings.TrimSpace(strings.ToLower(email)) |
| if email == "" { |
| @@ -152,7 +187,11 @@ func (s *Store) EnsurePerson(ctx context.Context, email string) (int64, error) { |
| // A concurrent first sign-in from the same brand-new address |
| // can race two inserts against the unique index on email; the |
| // loser looks up what the winner just created rather than |
| - // failing the request over a benign collision. |
| + // failing the request over a benign collision. The loser never |
| + // seeds: only the goroutine whose INSERT actually won reaches |
| + // seedIfEnabled below, so two simultaneous first sign-ins for |
| + // the same address still produce exactly one set of sample |
| + // documents, not two. |
| if errors.Is(err, gorm.ErrDuplicatedKey) { |
| if err2 := s.db.WithContext(ctx).Where("email = ?", email).First(&p).Error; err2 == nil { |
| return p.ID, nil |
| @@ -160,9 +199,53 @@ func (s *Store) EnsurePerson(ctx context.Context, email string) (int64, error) { |
| } |
| return 0, fmt.Errorf("docs: create person %q: %w", email, err) |
| } |
| + |
| + s.seedIfEnabled(ctx, p.ID, email) |
| return p.ID, nil |
| } |
| +// seedIfEnabled seeds newOwner's document list with CARLOS Docs' own |
| +// sample content, when this instance has opted in |
| +// (Config.SampleDocuments, threaded through as Store.sampleDocuments) |
| +// — called only from EnsurePerson's just-created-the-row branch above, |
| +// never from its found-an-existing-row branch, and never called twice |
| +// for the same person: EnsurePerson's own doc comment has the full |
| +// reasoning for why that hook point is the one that gives "seed once, |
| +// per person, ever" for free rather than needing its own bookkeeping. |
| +// |
| +// This runs after the Create above has already returned nil — not |
| +// wrapped in a transaction of its own, and deliberately not folded |
| +// into a transaction around Create either (there isn't one today: |
| +// that INSERT already autocommits on its own). Seed's own work below |
| +// is six Store.Create calls, six Store.ApplyUpdate calls — each of |
| +// which replays a document's update log and applies a fresh one |
| +// through internal/core's WebAssembly runtime, not a trivial |
| +// in-transaction operation — and six backdating UPDATEs. Holding all |
| +// of that, plus the person row's own INSERT, inside one atomic unit |
| +// would mean a slow seed holds whatever locks SQLite takes for the |
| +// duration, and would mean a seed failure could take the newly |
| +// created person row down with it on rollback. Neither is |
| +// acceptable: a person's own row must be durable the moment Create |
| +// above succeeds, regardless of what happens next. |
| +// |
| +// So the error boundary sits here, not in Seed and not in |
| +// EnsurePerson's return: a failure is logged, with the address and |
| +// the new person id for context, and then dropped on the floor. |
| +// EnsurePerson still returns (p.ID, nil) no matter what happens in |
| +// here. A person who cannot sign in because their sample content |
| +// failed to generate is a far worse outcome than a person who signs |
| +// in to an empty list — the second is this feature not having worked |
| +// yet; the first is this feature having broken sign-in, which is the |
| +// exact failure this whole plan exists to fix, not reintroduce. |
| +func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string) { |
| + if !s.sampleDocuments { |
| + return |
| + } |
| + if err := Seed(ctx, s, newOwner); err != nil { |
| + s.logger.Error("docs: seed sample documents for new person", "person", newOwner, "address", email, "err", err) |
| + } |
| +} |
| + |
| // Create makes a new document owned by owner: a GUID and a client id |
| // assigned here by the app (never drawn from yrs — see Document's doc |
| // comment) and stored, an app-generated sqid, and the given title. It |