Documentation
¶
Overview ¶
Package comments is the durable half of letting your users talk about your things: a scoped table of what was said, threaded one level deep, about targets this package deliberately cannot see.
Every product grows this table. A form somewhere collects some text, a row is written against whatever it was about, somebody replies to it, and then the same scoping, paging, editing, archiving and erasing gets written again in each application. What varies is the catalog of things that can be commented on, and that is the one part this package refuses to guess at.
The target, and why the store cannot check it ¶
A comment is about something: a recipe, a meal plan, another user's post. Which kinds of thing exist is an application fact, and the rows themselves live in tables this package has never seen — in another schema, sometimes in another database. There is no foreign key available to it and no join it could make.
So the vocabulary is supplied, not inferred. WithTargets takes a Targets catalog: the TargetType values this application accepts comments on, each with a description and, optionally, an existence check the consumer supplies. A write naming a type outside the catalog is ErrUnknownTargetType; a write whose registered check cannot find the target is ErrTargetNotFound. The catalog is the webhooks event catalog's idea applied to a second problem, for the same reason: a target type is a string underneath, and a comment written under a misspelled one is stored, counted, and shown nowhere.
Reads are not gated on the catalog. See Store for the argument — briefly, the catalog stops a comment being written where nothing will list it, and the type that has been withdrawn from a catalog is exactly the one whose rows an operator still needs to reach.
Dangling targets: the ruling ¶
A comment can outlive the thing it is about, and this package cannot stop that. It is worth saying plainly rather than leaving to be discovered:
Referential integrity between this table and an application's own tables is impossible by construction. The existence check runs at the write and answers for that moment only; nothing here observes the consumer's deletes, and no database constraint can span a schema this package does not know the shape of. A target that is hard-deleted leaves its comments in place, live, listable, and about nothing.
What that means in practice:
Store.ListCommentsByTargetType and Store.ListRootComments will return comments whose target is gone. They are not corrupt rows and they are not filtered out, because filtering them out would mean a read that consults the consumer's tables, which is the thing that cannot be done.
The fix is the consumer's, and it has one shape: Store.DeleteCommentsForTarget, called from the transaction that removes the target. It takes a database.Tx rather than reaching for the store's own writer precisely so that it can be — a sweep outside that transaction is a window in which the target is gone and its comments are not.
Where the deletion is a person rather than a thing, the same job belongs to an eraser: comments/privacy ships one, and registering it puts a subject's comments in the same transaction as the rest of their footprint.
A consumer that does neither accumulates dangling comments. They cost storage and they surface as a moderation queue full of rows about nothing. Nothing in this package will report it, because nothing in this package can see it.
An existence check registered on a target definition narrows the window; it does not close it. A target deleted between the check and the insert is a comment about nothing, written by a store that had just been told the target was there.
Threads are one level deep ¶
A comment has a ParentID, and RootParentID — the empty string — is a comment that replies to nothing. A reply's parent must be a live root in the same scope and on the same target; a reply to a reply is ErrNestedReply.
The depth limit is a reading decision rather than a storage one. A parent id admits any depth; assembling an arbitrarily deep tree does not. That is a recursive walk, and a recursive walk is neither one statement nor the same statement on the three engines this package serves — so a store that accepted depth would be a store whose reads could not return what it stored. One level is the depth a flat pair of reads can answer: the target's roots, then one root's replies.
A reply may outlive its parent. Archiving a root does not archive its replies, and erasing an author's root comment leaves replies parented to a row that is no longer there. Both are deliberate, and they are one case: a reply is still a reply, Store.ListReplies still finds it by parent id, and "in reply to a removed comment" is what every discussion UI already renders. A consumer that wants the subtree gone enumerates the replies and archives them too.
Tenancy ¶
Every read and write takes a tenancy.Scope, and there is no variant of anything that omits one. A deployment with a single tenant passes tenancy.Global() everywhere and behaves exactly as it would have without the column.
There is deliberately no cross-scope listing — see Store for what that costs and why the alternative is worse.
Personal data ¶
The body is a sentence somebody typed, and nothing can promise a sentence somebody typed names nobody. So this table meets the dataprivacy seam like any other store of personal data, and comments/privacy ships the two halves: a dataprivacy.Collector that returns what a subject wrote, and a dataprivacy.Eraser that destroys it.
The erasure is a hard delete rather than an anonymization, and the reason is that there is nothing to anonymize down to. Stripping the author off a comment leaves the free text, which is the part that identifies people; keeping the text and losing the author would be a worse outcome than either.
Where the SQL comes from ¶
The store executes no SQL this module has not checked against its own schema. comments/internal/queries describes the table as data; a generator renders that into one .sql per dialect; sqlc checks each against the DDL comments/migrations ships; and sqlc-gen-unison turns the checked statements into the typed querier the store calls. A column renamed in a migration is a failed generate rather than a runtime scan error, on all three dialects at once.
make generate # re-renders internal/queries/<dialect>_generated.sql make unison # re-renders the schema and the generated querier
Getting the table ¶
comments/migrations renders the DDL for a dialect and a table prefix. It ships no numbered migration file, because migration numbers are global per consumer; hand migrations.SQL to database/migrate's WithGeneratedMigration and the table is created by your own migration run.
Example ¶
A discussion is one write per comment and two reads: the target's roots, then one root's replies.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/primandproper/platform-go/v13/comments"
"github.com/primandproper/platform-go/v13/comments/migrations"
"github.com/primandproper/platform-go/v13/database"
"github.com/primandproper/platform-go/v13/database/dialect"
"github.com/primandproper/platform-go/v13/database/sqlite"
"github.com/primandproper/platform-go/v13/tenancy"
)
// The application's own vocabulary, declared as constants so the catalog below
// and every call site agree by type rather than by spelling.
const recipeTarget comments.TargetType = "recipe"
func main() {
ctx := context.Background()
store, err := comments.NewSQLStore(exampleClient(ctx),
comments.WithTargets(comments.Targets{
recipeTarget: {Description: "a recipe"},
mealTarget: {Description: "a meal"},
}))
if err != nil {
panic(err)
}
scope := tenancy.Of("acct_1")
recipe := comments.Target{Type: recipeTarget, ID: "recipe_1"}
root := &comments.Comment{
Scope: scope,
Target: recipe,
Author: "user_1",
Body: "halved the sugar and it was still too sweet",
}
if err = store.CreateComment(ctx, root); err != nil {
panic(err)
}
// A reply names its parent and nothing else about where it goes: its target
// is its parent's, and the store fills it in.
answer := &comments.Comment{
Scope: scope,
ParentID: root.ID,
Author: "user_2",
Body: "try two thirds of the syrup as well",
}
if err = store.CreateComment(ctx, answer); err != nil {
panic(err)
}
fmt.Println("reply is about:", answer.Target.Type, answer.Target.ID)
// The top of the discussion. The count beside the page is of every root on
// the target rather than of the page, so a client asking for ten still knows
// how many there are.
roots, err := store.ListRootComments(ctx, scope, recipe, nil)
if err != nil {
panic(err)
}
fmt.Println("roots:", roots.FilteredCount)
replies, err := store.ListReplies(ctx, scope, recipe, root.ID, nil)
if err != nil {
panic(err)
}
fmt.Println("replies to the first:", replies.FilteredCount)
}
// exampleClient is a throwaway SQLite database with the comments table in it, so
// the examples above run as written.
func exampleClient(ctx context.Context) database.Client {
dir, err := os.MkdirTemp("", "comments-example")
if err != nil {
panic(err)
}
client, err := sqlite.NewDatabaseClient(ctx,
&exampleClientConfig{connectionString: filepath.Join(dir, "comments.db")})
if err != nil {
panic(err)
}
stmts, err := migrations.Statements(dialect.SQLite, comments.DefaultTablePrefix)
if err != nil {
panic(err)
}
for _, stmt := range stmts {
if _, err = client.Writer().ExecContext(ctx, stmt); err != nil {
panic(err)
}
}
return client
}
type exampleClientConfig struct {
connectionString string
}
func (c *exampleClientConfig) GetReadConnectionString() string { return c.connectionString }
func (c *exampleClientConfig) GetWriteConnectionString() string { return c.connectionString }
func (c *exampleClientConfig) GetMaxPingAttempts() uint64 { return 1 }
func (c *exampleClientConfig) GetPingWaitPeriod() time.Duration { return time.Millisecond }
func (c *exampleClientConfig) GetMaxIdleConns() int { return 2 }
func (c *exampleClientConfig) GetMaxOpenConns() int { return 1 }
func (c *exampleClientConfig) GetConnMaxLifetime() time.Duration { return time.Minute }
Output: reply is about: recipe recipe_1 roots: 1 replies to the first: 1
Index ¶
- Constants
- Variables
- type Comment
- type SQLStore
- func (s *SQLStore) ArchiveComment(ctx context.Context, scope tenancy.Scope, commentID string) error
- func (s *SQLStore) CreateComment(ctx context.Context, comment *Comment) error
- func (s *SQLStore) DeleteCommentsByAuthor(ctx context.Context, q database.Tx, scope tenancy.Scope, author string) (int64, error)
- func (s *SQLStore) DeleteCommentsForTarget(ctx context.Context, q database.Tx, scope tenancy.Scope, target Target) (int64, error)
- func (s *SQLStore) GetComment(ctx context.Context, scope tenancy.Scope, commentID string) (*Comment, error)
- func (s *SQLStore) ListCommentsByAuthor(ctx context.Context, scope tenancy.Scope, author string, ...) (*filtering.QueryFilteredResult[Comment], error)
- func (s *SQLStore) ListCommentsByTargetType(ctx context.Context, scope tenancy.Scope, targetType TargetType, ...) (*filtering.QueryFilteredResult[Comment], error)
- func (s *SQLStore) ListReplies(ctx context.Context, scope tenancy.Scope, target Target, parentID string, ...) (*filtering.QueryFilteredResult[Comment], error)
- func (s *SQLStore) ListRootComments(ctx context.Context, scope tenancy.Scope, target Target, ...) (*filtering.QueryFilteredResult[Comment], error)
- func (s *SQLStore) TablePrefix() string
- func (s *SQLStore) TargetTypes() []TargetType
- func (s *SQLStore) UpdateComment(ctx context.Context, comment *Comment) error
- type SQLStoreOption
- func WithStoreLogger(logger logging.Logger) SQLStoreOption
- func WithStoreMetricsProvider(metricsProvider metrics.Provider) SQLStoreOption
- func WithStorePillars(p *observability.Pillars) SQLStoreOption
- func WithStoreTracerProvider(tracerProvider tracing.Provider) SQLStoreOption
- func WithTablePrefix(prefix string) SQLStoreOption
- func WithTargets(targets Targets) SQLStoreOption
- type Store
- type Target
- type TargetDefinition
- type TargetExistsFunc
- type TargetType
- type Targets
Examples ¶
Constants ¶
const AuthorAttributeKey = authorKey
AuthorAttributeKey is the metric and span attribute a caller labels its own instruments with when the thing being measured is about one author. It is exported so a consumer's attributes agree with this package's rather than merely resembling them.
const DefaultTablePrefix = ""
DefaultTablePrefix is the namespace the comments table carries when none is configured, which is none — rendering comments.
The comments name is the schema's, not the caller's: a table always says which package created it. Setting a namespace of "ddb" renders ddb_comments, for a database shared between applications. A namespace must not end in '_'; database/ddl supplies the separator.
const RootParentID = ""
RootParentID is the parent a comment that replies to nothing has: none.
It is exported because it is a stored value rather than an internal sentinel — it is what parent_id holds for a root, and it is what Store.ListRootComments binds. A caller assembling a thread from a page of comments compares against it rather than against a literal "".
It is the empty string rather than NULL for a reason that shows up in the SQL: "the roots of this target" is then an equality against a bound value, which makes the root list and the reply list one statement instead of two. See comments/internal/queries.
Variables ¶
var ( // ErrNilDatabaseClient indicates a nil database.Client. It wraps // errors.ErrNilInputParameter, so a caller may check either. ErrNilDatabaseClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil database client") // ErrNilComment indicates a nil *Comment where one was required. ErrNilComment = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil comment") // ErrNilExecutor indicates a nil database.Tx handed to a write that runs // inside somebody else's transaction. ErrNilExecutor = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil query executor") // ErrCommentNotFound indicates a comment that does not exist in the scope // that asked. One belonging to another scope reads as absent — which is what // it is from here, and is the answer that does not turn a read into an oracle // for what other tenants have been saying. ErrCommentNotFound = platformerrors.New("comment not found") // ErrUnknownTargetType indicates a target type nobody registered. // // It is the catalog's whole job. A target type is a string underneath, and a // comment written under a misspelled one is stored, counted, and listed // nowhere — an absence somebody has to notice rather than an error somebody // is told about. ErrUnknownTargetType = platformerrors.New("unknown comment target type") // ErrTargetNotFound indicates a target the consumer's registered existence // check could not find. // // It is distinct from ErrUnknownTargetType: that one is a kind of thing this // application does not have, and this one is a kind of thing it has and one // of them that is not there. A client shown the first has a bug; a client // shown the second has a stale list. ErrTargetNotFound = platformerrors.New("comment target not found") // ErrEmptyTargetType indicates a target naming no kind of thing. ErrEmptyTargetType = platformerrors.New("empty comment target type") // ErrEmptyTargetID indicates a target naming a kind of thing and not one of // them. It is refused rather than stored, because the empty id is not a // wildcard: a comment holding it is about every recipe and no recipe at once. ErrEmptyTargetID = platformerrors.New("empty comment target id") // ErrEmptyAuthor indicates a comment written by nobody. // // It is refused rather than stored, because the empty author is not a // wildcard and is not "anonymous": a comment written under it is one no // author's list can find and one no subject access request can collect or // erase. ErrEmptyAuthor = platformerrors.New("empty comment author") // ErrEmptyBody indicates a comment with nothing in it. The body is the // comment; a row without one records that somebody pressed a button. ErrEmptyBody = platformerrors.New("empty comment body") // ErrEmptyParent indicates a read of one comment's replies that named no // comment. It is refused rather than answered with the roots, which is what // the empty parent means in the column and would be the wrong half of the // discussion returned without anything saying so. ErrEmptyParent = platformerrors.New("empty comment parent") // ErrParentNotFound indicates a reply to a comment that is not in the scope: // absent, archived, or somebody else's. // // It is distinct from ErrCommentNotFound because the missing row is not the // one the caller named — they named a reply, and what is missing is what they // are replying to. ErrParentNotFound = platformerrors.New("comment parent not found") // ErrNestedReply indicates a reply to a reply. // // Threads here are one level deep. A parent id admits any depth and the // reading of it does not: assembling an arbitrarily deep tree is a recursive // walk, which is neither one statement nor the same statement on the three // engines this package serves. See the package documentation. ErrNestedReply = platformerrors.New("a comment reply may not itself be replied to") // ErrTargetMismatch indicates a reply that names a different target than the // comment it replies to. A reply belongs to its parent's discussion; one that // could name another target would be a comment that appears under a thing // nobody said it about. ErrTargetMismatch = platformerrors.New("comment reply names a different target than its parent") )
The sentinels this package returns. They live together because a caller deciding what to do next is choosing between them, and a set spread across the files that happen to return each one cannot be read as the set it is.
Functions ¶
This section is empty.
Types ¶
type Comment ¶
type Comment struct {
// CreatedAt is when the comment was written, assigned by the database.
CreatedAt time.Time `json:"createdAt"`
// LastUpdatedAt is when the body was last edited, and nil for a comment
// nobody has revised.
//
// It is what a client renders an "edited" marker from. That reading holds
// because the edit is the only write that revises a live comment: the target,
// the parent and the author are not editable facts, and the archive takes the
// row out of every list rather than changing what it says.
LastUpdatedAt *time.Time `json:"lastUpdatedAt,omitempty"`
// ArchivedAt is when the comment was removed from the discussion, and nil
// while it is still in it.
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
// ID identifies the comment.
ID string `json:"id"`
// ParentID is the comment this one replies to, and RootParentID — the empty
// string — is a comment that replies to nothing.
//
// Replies are one level deep: a comment whose parent is itself a reply is
// refused. See the package documentation for why, and for what a client
// renders when a reply's parent is no longer there.
ParentID string `json:"parentID,omitempty"`
// Author is who wrote it — a user id in every deployment this module has,
// but a string here, because comments does not own the directory.
Author string `json:"author"`
// Body is what the person actually said.
Body string `json:"body"`
// Target is what the comment is about.
Target Target `json:"target"`
// Scope is whose data this is.
Scope tenancy.Scope `json:"scope"`
}
Comment is something somebody said, about something the application owns, possibly in reply to something else somebody said.
It is deliberately app-independent. Target is how the application names the thing being discussed, and the catalog the store was built with is what says which types exist; Author is a string because this package does not own the directory people live in. What this package owns is everything around them — the scope, the thread, the page, the erasure — which is the half that is the same in every application and the half every application otherwise writes again.
type SQLStore ¶
type SQLStore struct {
// contains filtered or unexported fields
}
SQLStore is the SQL-backed Store, against the schema comments/migrations renders.
It is exported, and returned by NewSQLStore, so a caller who has chosen SQL storage can depend on that choice rather than on the seam every backing shares.
func NewSQLStore ¶
func NewSQLStore(client database.Client, opts ...SQLStoreOption) (*SQLStore, error)
NewSQLStore builds a comment store over the given database.
The dialect comes from the client, so the two cannot disagree. The prefix must still match the one the migrations were rendered with — nothing here can check that, and a mismatch surfaces as a missing table on the first query rather than at construction.
The target catalog is an option and it defaults to empty, which is a store that refuses every write and answers every read. That is the same reading webhooks takes of its event catalog, and it is the safe one: a store built without a catalog is a wiring mistake, and a wiring mistake that stores rows under types nothing lists is worse than one that fails on the first write.
Observability is optional and defaults to nothing: an unconfigured store logs to a noop logger, traces to a noop provider, and counts into a noop meter.
func (*SQLStore) ArchiveComment ¶
func (s *SQLStore) ArchiveComment( ctx context.Context, scope tenancy.Scope, commentID string, ) error
ArchiveComment removes one comment from the discussion.
Zero rows is ErrCommentNotFound rather than a quiet success, and the reading is exact: the statement excludes archived rows, so a comment that has already been archived is not in the discussion, which is what this method addresses.
func (*SQLStore) CreateComment ¶
CreateComment writes one comment and reads back the creation time the database assigned.
The read-back is a second round trip on a write path, and it is worth it: created_at is database-owned — see comments/internal/queries — so the insert does not carry it, and the alternative is a value whose CreatedAt says 0001-01-01 for a row written a moment ago. A service that serializes what it just created straight into a response would render that as a date rather than as an absence.
func (*SQLStore) DeleteCommentsByAuthor ¶
func (s *SQLStore) DeleteCommentsByAuthor( ctx context.Context, q database.Tx, scope tenancy.Scope, author string, ) (int64, error)
DeleteCommentsByAuthor destroys everything one person wrote within the scope and reports how many that was.
Zero is not an error. An erasure runs against whatever the subject actually left behind, and a person who never commented is a person with nothing here to erase — reporting that as a failure would fail an erasure that succeeded.
func (*SQLStore) DeleteCommentsForTarget ¶
func (s *SQLStore) DeleteCommentsForTarget( ctx context.Context, q database.Tx, scope tenancy.Scope, target Target, ) (int64, error)
DeleteCommentsForTarget destroys every comment about one thing and reports how many that was.
Zero is not an error. The sweep runs against whatever the target actually collected, and a thing nobody commented on is a thing with nothing here to remove — reporting that as a failure would fail a delete that succeeded.
func (*SQLStore) GetComment ¶
func (s *SQLStore) GetComment( ctx context.Context, scope tenancy.Scope, commentID string, ) (*Comment, error)
GetComment reads one of the scope's live comments.
func (*SQLStore) ListCommentsByAuthor ¶
func (s *SQLStore) ListCommentsByAuthor( ctx context.Context, scope tenancy.Scope, author string, filter *filtering.QueryFilter, ) (*filtering.QueryFilteredResult[Comment], error)
ListCommentsByAuthor pages what one person wrote within the scope.
func (*SQLStore) ListCommentsByTargetType ¶
func (s *SQLStore) ListCommentsByTargetType( ctx context.Context, scope tenancy.Scope, targetType TargetType, filter *filtering.QueryFilter, ) (*filtering.QueryFilteredResult[Comment], error)
ListCommentsByTargetType pages every comment about one kind of thing.
func (*SQLStore) ListReplies ¶
func (s *SQLStore) ListReplies( ctx context.Context, scope tenancy.Scope, target Target, parentID string, filter *filtering.QueryFilter, ) (*filtering.QueryFilteredResult[Comment], error)
ListReplies pages one root comment's replies.
func (*SQLStore) ListRootComments ¶
func (s *SQLStore) ListRootComments( ctx context.Context, scope tenancy.Scope, target Target, filter *filtering.QueryFilter, ) (*filtering.QueryFilteredResult[Comment], error)
ListRootComments pages the top level of one target's discussion.
func (*SQLStore) TablePrefix ¶
TablePrefix returns the namespace this store's table carries, for a caller rendering the migrations it needs.
func (*SQLStore) TargetTypes ¶
func (s *SQLStore) TargetTypes() []TargetType
TargetTypes returns the target types this store was built to accept, sorted.
It is here so that the console rendering "what can be commented on" reads the catalog the store is actually enforcing rather than a second copy of it.
type SQLStoreOption ¶
type SQLStoreOption func(*SQLStore)
SQLStoreOption configures a SQLStore.
The observability dependencies are options rather than parameters because every one of them is genuinely optional: an absent logger logs nowhere, an absent tracer provider traces nowhere, and an absent metrics provider records nothing. A caller wanting none of the three names none of them.
func WithStoreLogger ¶
func WithStoreLogger(logger logging.Logger) SQLStoreOption
WithStoreLogger attaches a logger. An absent logger logs nowhere.
func WithStoreMetricsProvider ¶
func WithStoreMetricsProvider(metricsProvider metrics.Provider) SQLStoreOption
WithStoreMetricsProvider attaches a metrics provider. An absent provider records nothing.
func WithStorePillars ¶
func WithStorePillars(p *observability.Pillars) SQLStoreOption
WithStorePillars attaches a logger, tracer provider, and metrics provider in one go. A nil Pillars attaches nothing.
Options apply in order, so a caller can hand over its pillars and then override one of them.
func WithStoreTracerProvider ¶
func WithStoreTracerProvider(tracerProvider tracing.Provider) SQLStoreOption
WithStoreTracerProvider attaches a tracer provider, enabling spans on every read and write. An absent provider traces nowhere.
It takes a provider rather than a ready-made tracer so that the spans this package emits carry this package's instrumentation scope. A caller-supplied tracer would attribute them to whoever built it.
func WithTablePrefix ¶
func WithTablePrefix(prefix string) SQLStoreOption
WithTablePrefix namespaces the comments table. It must match the prefix the migrations were rendered with; nothing here can check that, and a mismatch surfaces as a missing table on the first query rather than at construction.
func WithTargets ¶
func WithTargets(targets Targets) SQLStoreOption
WithTargets supplies the kinds of thing this application accepts comments on. A store built without it accepts none, which is the reading webhooks takes of an absent event catalog and for the same reason.
The catalog is copied rather than retained, so a consumer that keeps mutating the map it passed does not quietly change what the store enforces. A catalog that should change is a store that should be rebuilt.
Applying it twice replaces rather than merges: two calls are two answers to "what can be commented on", and merging them would make the store accept a type neither call meant on its own.
Example ¶
The catalog is what stops a comment being written where nothing will list it. A target type nobody registered is refused at the write rather than stored and discovered as an absence.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/primandproper/platform-go/v13/comments"
"github.com/primandproper/platform-go/v13/comments/migrations"
"github.com/primandproper/platform-go/v13/database"
"github.com/primandproper/platform-go/v13/database/dialect"
"github.com/primandproper/platform-go/v13/database/sqlite"
"github.com/primandproper/platform-go/v13/tenancy"
)
func main() {
ctx := context.Background()
store, err := comments.NewSQLStore(exampleClient(ctx),
comments.WithTargets(comments.Targets{
recipeTarget: {Description: "a recipe"},
}))
if err != nil {
panic(err)
}
misspelled := &comments.Comment{
Scope: tenancy.Of("acct_1"),
Target: comments.Target{Type: "recipies", ID: "recipe_1"},
Author: "user_1",
Body: "this would have been stored under a type nothing lists",
}
fmt.Println("refused:", store.CreateComment(ctx, misspelled) != nil)
fmt.Println("what can be commented on:", store.TargetTypes())
}
// exampleClient is a throwaway SQLite database with the comments table in it, so
// the examples above run as written.
func exampleClient(ctx context.Context) database.Client {
dir, err := os.MkdirTemp("", "comments-example")
if err != nil {
panic(err)
}
client, err := sqlite.NewDatabaseClient(ctx,
&exampleClientConfig{connectionString: filepath.Join(dir, "comments.db")})
if err != nil {
panic(err)
}
stmts, err := migrations.Statements(dialect.SQLite, comments.DefaultTablePrefix)
if err != nil {
panic(err)
}
for _, stmt := range stmts {
if _, err = client.Writer().ExecContext(ctx, stmt); err != nil {
panic(err)
}
}
return client
}
type exampleClientConfig struct {
connectionString string
}
func (c *exampleClientConfig) GetReadConnectionString() string { return c.connectionString }
func (c *exampleClientConfig) GetWriteConnectionString() string { return c.connectionString }
func (c *exampleClientConfig) GetMaxPingAttempts() uint64 { return 1 }
func (c *exampleClientConfig) GetPingWaitPeriod() time.Duration { return time.Millisecond }
func (c *exampleClientConfig) GetMaxIdleConns() int { return 2 }
func (c *exampleClientConfig) GetMaxOpenConns() int { return 1 }
func (c *exampleClientConfig) GetConnMaxLifetime() time.Duration { return time.Minute }
Output: refused: true what can be commented on: [recipe]
type Store ¶
type Store interface {
// CreateComment writes one comment, under the scope the value carries. It
// assigns the id where the caller left it empty and writes back what was
// stored.
//
// The target is checked against the catalog the store was built with, and
// against that definition's existence hook where one is registered: an
// unregistered type is an error wrapping ErrUnknownTargetType, and a target
// the hook cannot find is ErrTargetNotFound.
//
// A comment with a ParentID is a reply, and three things are true of one. Its
// parent must be a live comment in the same scope, or the write is
// ErrParentNotFound. Its parent must itself be a root, or the write is
// ErrNestedReply. And it belongs to its parent's discussion: a reply that
// names no target adopts the parent's, and one that names a different target
// is ErrTargetMismatch.
CreateComment(ctx context.Context, comment *Comment) error
// GetComment reads one of the scope's live comments. It returns an error
// wrapping ErrCommentNotFound when the comment does not exist, has been
// archived, or belongs to another scope — which are the same answer from
// here.
GetComment(ctx context.Context, scope tenancy.Scope, commentID string) (*Comment, error)
// ListRootComments pages the top level of one target's discussion: the
// comments that reply to nothing.
//
// It is the read a discussion opens with, and it is a separate method from
// ListReplies rather than a parent argument a caller leaves empty, because
// "the roots" and "this comment's replies" are two questions somebody asks
// deliberately. Underneath they are one statement with a different bound
// value — see comments/internal/queries — which is what keeps them from
// answering differently.
//
// The count a client wants beside the discussion is on the result's
// pagination: the filtered count is of the target's roots, not of the page.
ListRootComments(ctx context.Context, scope tenancy.Scope, target Target, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Comment], error)
// ListReplies pages one root comment's replies.
//
// The target is a parameter as well as the parent because a reply carries
// both and the statement keys on both: a reply's target is its parent's, so
// naming it costs the caller nothing and buys the read the index it was
// written for.
//
// An empty parent is ErrEmptyParent rather than the roots, which is what the
// empty parent means in the column — returning them would be the wrong half
// of the discussion, with nothing about the rows saying so.
//
// A parent that is no longer there is not an error. A reply outlives the
// comment it replies to — archived, or erased with its author — and it is
// still a reply; see the package documentation.
ListReplies(ctx context.Context, scope tenancy.Scope, target Target, parentID string, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Comment], error)
// ListCommentsByTargetType pages every comment about one kind of thing —
// "everything anybody has said about recipes", roots and replies alike.
//
// It is the moderation read, and it is the read an operator withdrawing a
// target type runs first, to see what withdrawing it would strand. It does
// not gate on the catalog, and that is why.
ListCommentsByTargetType(ctx context.Context, scope tenancy.Scope, targetType TargetType, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Comment], error)
// ListCommentsByAuthor pages what one person wrote within the scope. It is
// what a "your comments" view reads, and what the subject access request
// collector pages through.
ListCommentsByAuthor(ctx context.Context, scope tenancy.Scope, author string, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Comment], error)
// UpdateComment revises what the author said, and only that.
//
// It does not move the comment: the target is what the comment is about and
// was checked against the catalog when it was written, the parent is which
// conversation it is in, and the author is who said it. A whole-row write
// that assigned any of the three would be an edit that silently moved
// somebody else's words.
//
// A comment that is not in the scope — absent, archived, or somebody else's —
// is an error wrapping ErrCommentNotFound.
UpdateComment(ctx context.Context, comment *Comment) error
// ArchiveComment removes one comment from the discussion, leaving the row for
// whoever asks later what was said.
//
// It archives exactly the comment named. A root's replies stay where they
// are, which is deliberate: a moderator removing an off-topic root has not
// removed the answers to it, and a reply whose parent is gone is what every
// discussion UI already renders as a reply to a removed comment. A consumer
// that wants the whole subtree gone archives the replies too, which
// ListReplies enumerates.
//
// A comment already archived is an error wrapping ErrCommentNotFound,
// because an archived comment is not in the discussion and this method
// addresses the discussion.
ArchiveComment(ctx context.Context, scope tenancy.Scope, commentID string) error
// DeleteCommentsForTarget destroys every comment about one thing — replies
// and archived rows included — and reports how many that was.
//
// It is the sweep the package documentation's dangling-target ruling names.
// A comment's target lives in a table this package has never seen, so nothing
// here cascades from that table's delete; the consumer calls this from the
// transaction that removes the target, which is why it takes an executor
// rather than reaching for the store's own.
//
// Zero is not an error: a thing nobody commented on is a thing with nothing
// here to sweep.
DeleteCommentsForTarget(ctx context.Context, q database.Tx, scope tenancy.Scope, target Target) (int64, error)
// DeleteCommentsByAuthor destroys everything one person wrote within the
// scope, archived comments included, and reports how many that was.
//
// It is a hard delete and it is the erasure path: the body is free text
// somebody wrote, so what a right-to-be-forgotten request has to remove is
// the words rather than a flag beside them. comments/privacy is the
// dataprivacy.Eraser built on this.
//
// It runs inside the caller's transaction and must use the executor it is
// given, so that a subject's comments and the rest of their footprint commit
// or roll back together.
DeleteCommentsByAuthor(ctx context.Context, q database.Tx, scope tenancy.Scope, author string) (int64, error)
}
Store is the persistence seam for comments.
This package ships a SQL implementation (NewSQLStore) together with the DDL it needs (comments/migrations), so adopting it does not mean writing this. The interface exists because a discussion and its storage are genuinely separable, and an application with its own schema conventions should not have to fork the package to keep them.
Every method takes a tenancy.Scope, and none of them offers a variant that omits it — an implementation must filter on it rather than treat it as a hint. A deployment with one tenant passes tenancy.Global() everywhere and behaves exactly as it would have without the column.
There is deliberately no cross-scope listing, and it is worth being clear about what that costs. An operator moderating every tenant's comments from one console is a real thing to want, and this interface will not answer it in one call: they list the scopes they administer and page each. The alternative is a read that omits the scope, which is the one read that cannot tell an operator's caller from a tenant's — and a paged list cannot bind a set of scopes either, because a bound set may not sit in a statement that also binds a cursor and a page size on two of the three dialects this package serves.
The catalog gates writes, not reads ¶
A comment names a target type, and the catalog the store was built with is what says which types exist. Store.CreateComment refuses one the catalog does not hold; no read does.
That is not an oversight. The catalog exists to stop a comment being written where nothing will ever list it — the misspelling that produces rows no view shows — and that failure is at the write. A read of a type the catalog does not hold answers with the rows that are there, which is nothing at all, since the write gate is what stopped any from being written. The exception is the type that was withdrawn, and there the rows are exactly what the operator who withdrew it needs to reach: gating the read would make the catalog a mechanism for hiding rows, which is not what it is for.
type Target ¶
type Target struct {
// Type is the kind of thing, as the catalog spells it.
Type TargetType `json:"type"`
// ID is which one, as the application spells it.
ID string `json:"id"`
}
Target is what a comment is about: a kind of thing, and one of them.
It is one value in this package's API and two columns in its table, and both halves of that are deliberate. One value, because a target type without an id is not a target and every method that takes one takes both — passing them separately is how a call site ends up pairing one comment's type with another's id. Two columns, because a key like "recipes:1234" scopes by construction and cannot be indexed, filtered or enumerated as the two facts it is: "every comment about recipes" is a question the two-column shape answers and the composite one does not.
func (Target) Validate ¶
Validate reports whether the target is well-formed: both halves present, and neither of them whitespace.
It says nothing about whether the type is one the catalog holds or whether the thing exists — those are the store's checks, because only the store has the catalog. This is the shape check, and it is exported because a handler rejecting a malformed target before it reaches a store is a handler answering 400 instead of 500.
type TargetDefinition ¶
type TargetDefinition struct {
// Exists optionally checks that a target of this type is there before a
// comment is written against it. Nil is no check, which is the honest
// default: a consumer that cannot cheaply answer the question should not be
// made to answer it badly.
Exists TargetExistsFunc
// Description is human-facing prose naming what this type is.
Description string
}
TargetDefinition describes one kind of thing an application accepts comments on.
Description is what a moderation console shows beside the type, and the reason this is a struct rather than a set — a bare set would push every consumer into maintaining that text somewhere else, out of step with the types themselves.
type TargetExistsFunc ¶
TargetExistsFunc reports whether a target is there to be commented on.
It is the optional half of the catalog, and it is the only thing that can answer the question this package cannot: the row a comment is about lives in a table the consumer owns, in a schema this store has never seen, so there is no foreign key to lean on and no join to make. A definition that supplies one turns "a comment about a recipe that does not exist" into a refused write; a definition that does not leaves that comment writable, and the package documentation owns what happens to it afterwards.
It is called on the create path only, before the row is written, and it is given the scope the comment is being filed under — so a check that reads the consumer's own table reads it as the tenant, not across tenants.
An error is not "absent". A hook that cannot reach its table fails the write rather than deciding the target is gone, because those two answers lead to opposite actions and only one of them is recoverable by trying again.
type TargetType ¶
type TargetType string
TargetType names one kind of thing an application's users comment on. It is the value stored in target_type, the key a Targets catalog is keyed by, and half of what a Target is.
It is a defined type rather than a string so that an application's target types are declarable in a form both a reader and a type checker recognize:
const Recipe comments.TargetType = "recipe"
The type is what makes the set of them discoverable. A catalog has to list every kind of thing an application accepts comments on — a missing entry refuses the write — and keeping that list by hand beside the constants that are its source of truth is what makes it drift. Derived instead, the question "which constants are target types" has to be answerable, and answering it by matching on the constant's name means a convention nothing enforces. Declared type is a fact the compiler already holds and no one can spell wrong.
It is deliberately not an alias. An alias is indistinguishable from string to a type checker, which would leave the set exactly as undiscoverable as it was.
Nothing here constrains the format beyond refusing the empty string and surrounding space. Dots, colons, and underscores are all fine; the catalog is the authority on which values exist, and this package has no opinion beyond that.
func (TargetType) String ¶
func (t TargetType) String() string
String renders the target type as it is stored.
It exists for the observability seams that take an any and switch on its type: a defined string type is neither string nor fmt.Stringer to that switch and falls through to a reflective default, which records the same text by a slower path that nothing would flag if it stopped matching. Spelling the conversion at those call sites keeps what is recorded a decision rather than a fallback.
type Targets ¶
type Targets map[TargetType]TargetDefinition
Targets is the set of things an application accepts comments on, keyed by target type. It is supplied at construction rather than stored, because what a target type means is an application opinion and this package has none.
Writing a comment against a type outside the catalog is refused. That matters because a target type is a string underneath, string literals are typo-prone, and a comment written under "recipies" is a comment that is stored, is counted, and appears in no view — an absence somebody has to notice.
Reading is not gated, and the asymmetry is deliberate. See Store for the argument: the catalog exists to stop a comment being written where nothing will ever list it, and that failure is at the write. A read of a type the catalog does not hold answers with the rows that are there — which is nothing at all unless the type was withdrawn, and if it was withdrawn those rows are exactly what the operator withdrawing it needs to reach.
func (Targets) Known ¶
func (t Targets) Known(targetType TargetType) bool
Known reports whether targetType is in the catalog.
func (Targets) TargetTypes ¶
func (t Targets) TargetTypes() []TargetType
TargetTypes returns the catalog's target types, sorted, for rendering a moderation console or an API response.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package commentscfg assembles a comments Store from environment configuration.
|
Package commentscfg assembles a comments Store from environment configuration. |
|
internal
|
|
|
queries
Package queries is the comments schema described as data: the canonical table name, the table's columns in the order every read projects them, and the subsets each write assigns.
|
Package queries is the comments schema described as data: the canonical table name, the table's columns in the order every read projects them, and the subsets each write assigns. |
|
queriesgen
command
Package main renders the canonical sqlc input for the comments table, one .sql per dialect, from the table description in comments/internal/queries.
|
Package main renders the canonical sqlc input for the comments table, one .sql per dialect, from the table description in comments/internal/queries. |
|
Package migrations supplies the comments table's DDL, rendered for a dialect and table prefix.
|
Package migrations supplies the comments table's DDL, rendered for a dialect and table prefix. |
|
Package commentsmock provides moq-generated mock implementations of interfaces in the comments package.
|
Package commentsmock provides moq-generated mock implementations of interfaces in the comments package. |
|
Package privacy is the comments table's contribution to a subject access request: a dataprivacy.Collector that returns what somebody wrote, and a dataprivacy.Eraser that destroys it.
|
Package privacy is the comments table's contribution to a subject access request: a dataprivacy.Collector that returns what somebody wrote, and a dataprivacy.Eraser that destroys it. |