Add revision-aware CMS taxonomies and relationships
This commit is contained in:
@@ -2,6 +2,21 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.1.0-preview.27 — 2026-09-10
|
||||
|
||||
- Add independent `cms` values and a `cmssqlite` adapter for named taxonomies,
|
||||
stable terms, immutable revision associations and bidirectional editorial
|
||||
links. Applications retain typed content, templates, permissions and commerce.
|
||||
- Join caller-owned SQLite transactions so content, publication pointers and
|
||||
application audits commit together. Schema installation is explicit and
|
||||
independent of authentication schema 11, which remains unchanged.
|
||||
- Preserve term URL aliases and historical associations on rename/retirement.
|
||||
Query only published snapshots, with namespace isolation and bounded keyset
|
||||
pagination. The application still checks each target's current availability.
|
||||
- Include executable integration guidance and regressions for conflicts,
|
||||
rollback, draft isolation, reverse discovery, aliases and retirement. The
|
||||
consumer exercised native editing and publication behind trusted local TLS.
|
||||
|
||||
## v0.1.0-preview.26 — 2026-09-05
|
||||
|
||||
- Add optional self-profile readers and revision-checked username/display-name
|
||||
|
||||
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
|
||||
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||
packages you import.
|
||||
|
||||
> **Public preview:** `v0.1.0-preview.26`. APIs may change before a stable
|
||||
> **Public preview:** `v0.1.0-preview.27`. APIs may change before a stable
|
||||
> release. Linux is the maintained release platform.
|
||||
|
||||
## Why Web Foundations?
|
||||
@@ -44,6 +44,7 @@ packages you import.
|
||||
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
|
||||
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||
| Typed editorial categories and related-content discovery | [`cms`](cms) + [`cmssqlite`](cmssqlite); [integration guide](docs/CMS.md) |
|
||||
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
||||
| Organization-scoped roles and temporary access | [`access`](access) |
|
||||
| Application-classified request abuse | [`abuse`](abuse) |
|
||||
@@ -57,14 +58,14 @@ owns—and, just as importantly, what remains application policy.
|
||||
Pin the preview in an application module:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web@v0.1.0-preview.26
|
||||
go get gamertan.com/web@v0.1.0-preview.27
|
||||
go mod verify
|
||||
```
|
||||
|
||||
An application may name the first package it intends to adopt:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.26
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.27
|
||||
```
|
||||
|
||||
The version belongs to the `gamertan.com/web` module. See the
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||
|
||||
# Current work
|
||||
|
||||
## CMS classification and relationships
|
||||
|
||||
Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content:
|
||||
named flat taxonomies, stable terms with rename history, immutable revision
|
||||
associations, explicit publication and bidirectional related-content queries.
|
||||
Keep authorization, audits, application schemas, templates and commerce policy
|
||||
with their callers. Writes join caller-owned SQLite transactions.
|
||||
|
||||
Implemented: validated values, explicit schema, caller-owned transactions,
|
||||
immutable associations, publication pointers, term aliases and public readers.
|
||||
Verified: focused race tests for scope isolation, conflicts, draft/public
|
||||
transitions, retirement, rename collisions, pagination and rollback. Gamertan's
|
||||
consumer HTTP regressions now exercise real content/catalog/case-study routing,
|
||||
draft isolation, restore, role/CSRF checks and unchanged catalog data.
|
||||
|
||||
Consolidated evidence: the full package race suite passed; association fuzzing
|
||||
passed 698,275 executions. Dependency/module and license checks passed. Vet has
|
||||
only the existing vendored COSE diagnostic documented by `scripts/verify.sh`.
|
||||
The consumer's full Go suite and native trusted-TLS editor checks passed.
|
||||
|
||||
Next: public-export verification, then preview27 publication and an immutable
|
||||
consumer pin. No unrelated auth changes in this slice.
|
||||
|
||||
Status: implemented and consolidated Go/browser verification complete; export,
|
||||
upstream push and consumer deployment pending. Release history stays in CHANGELOG.
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package cms supplies bounded classification and editorial relationship values.
|
||||
// It does not define content types, layouts, authorization, or commerce policy.
|
||||
// Applications own those decisions and publish exact immutable revisions.
|
||||
package cms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("cms: invalid value")
|
||||
ErrConflict = errors.New("cms: revision or slug conflict")
|
||||
ErrNotFound = errors.New("cms: not found")
|
||||
identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`)
|
||||
slug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
kind = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
|
||||
)
|
||||
|
||||
const MaxTerms = 24
|
||||
const MaxLinks = 16
|
||||
|
||||
// Reference names an application-owned resource, never a mutable URL or title.
|
||||
type Reference struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func ValidID(value string) bool { return identifier.MatchString(value) }
|
||||
func ValidSlug(value string) bool { return len(value) <= 80 && slug.MatchString(value) }
|
||||
func ValidText(value string, max int) bool {
|
||||
return utf8.ValidString(value) && len(value) <= max && strings.TrimSpace(value) == value && !strings.ContainsFunc(value, unicode.IsControl)
|
||||
}
|
||||
func (r Reference) Validate() error {
|
||||
if !kind.MatchString(r.Kind) || !ValidID(r.ID) {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Associations are an immutable revision's term memberships and explicit links.
|
||||
// Reverse discovery reads the same links; callers must not store a second edge.
|
||||
type Associations struct {
|
||||
Terms []string `json:"terms,omitempty"`
|
||||
Links []Reference `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
func (a Associations) Validate(source Reference) error {
|
||||
if source.Validate() != nil || len(a.Terms) > MaxTerms || len(a.Links) > MaxLinks {
|
||||
return ErrInvalid
|
||||
}
|
||||
terms := map[string]bool{}
|
||||
for _, id := range a.Terms {
|
||||
if !ValidID(id) || terms[id] {
|
||||
return ErrInvalid
|
||||
}
|
||||
terms[id] = true
|
||||
}
|
||||
links := map[Reference]bool{}
|
||||
for _, ref := range a.Links {
|
||||
if ref.Validate() != nil || ref == source || links[ref] {
|
||||
return ErrInvalid
|
||||
}
|
||||
links[ref] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Taxonomy is an editor-named vocabulary, not a database-defined content type.
|
||||
// Slug is fixed after creation; Name/Description and availability may change.
|
||||
type Taxonomy struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Revision int64 `json:"revision"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (v Taxonomy) Validate() error {
|
||||
if !ValidID(v.ID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Term identity survives renaming and retirement. Retirement hides discovery;
|
||||
// it does not rewrite old associations or imply removal of related resources.
|
||||
type Term struct {
|
||||
ID string `json:"id"`
|
||||
TaxonomyID string `json:"taxonomy_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Revision int64 `json:"revision"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (v Term) Validate() error {
|
||||
if !ValidID(v.ID) || !ValidID(v.TaxonomyID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ResourceVersion struct {
|
||||
Reference
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
type Page struct {
|
||||
Items []ResourceVersion `json:"items"`
|
||||
Next *Reference `json:"next,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
package cms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssociationsBoundsAndIdentity(t *testing.T) {
|
||||
source := Reference{Kind: "project", ID: "project-one"}
|
||||
valid := Associations{Terms: []string{"go"}, Links: []Reference{{Kind: "news", ID: "launch"}}}
|
||||
if valid.Validate(source) != nil {
|
||||
t.Fatal("valid association rejected")
|
||||
}
|
||||
for _, value := range []Associations{
|
||||
{Terms: []string{"go", "go"}}, {Terms: []string{"../private"}},
|
||||
{Links: []Reference{source}}, {Links: []Reference{{Kind: "news", ID: "launch"}, {Kind: "news", ID: "launch"}}},
|
||||
{Links: []Reference{{Kind: "<script>", ID: "safe"}}},
|
||||
{Terms: make([]string, MaxTerms+1)}, {Links: make([]Reference, MaxLinks+1)},
|
||||
} {
|
||||
if value.Validate(source) == nil {
|
||||
t.Fatalf("accepted %#v", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestTaxonomyAndTermText(t *testing.T) {
|
||||
tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true}
|
||||
if tax.Validate() != nil {
|
||||
t.Fatal("valid taxonomy")
|
||||
}
|
||||
for _, name := range []string{"", " bad", "bad\nname", string([]byte{255}), strings.Repeat("a", 121)} {
|
||||
v := tax
|
||||
v.Name = name
|
||||
if v.Validate() == nil {
|
||||
t.Fatal("accepted invalid name")
|
||||
}
|
||||
}
|
||||
term := Term{ID: "go", TaxonomyID: tax.ID, Slug: "go", Name: "Go", Revision: 1, Active: true}
|
||||
if term.Validate() != nil {
|
||||
t.Fatal("valid term")
|
||||
}
|
||||
term.Slug = "../go"
|
||||
if term.Validate() == nil {
|
||||
t.Fatal("unsafe slug")
|
||||
}
|
||||
}
|
||||
func FuzzAssociations(f *testing.F) {
|
||||
f.Add(`{"terms":["go"],"links":[{"kind":"news","id":"launch"}]}`)
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
if len(raw) > 20000 {
|
||||
return
|
||||
}
|
||||
var value Associations
|
||||
if json.Unmarshal([]byte(raw), &value) != nil {
|
||||
return
|
||||
}
|
||||
if value.Validate(Reference{Kind: "project", ID: "one"}) == nil {
|
||||
b, e := json.Marshal(value)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var again Associations
|
||||
if json.Unmarshal(b, &again) != nil || again.Validate(Reference{Kind: "project", ID: "one"}) != nil {
|
||||
t.Fatal("round trip")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"gamertan.com/web/cms"
|
||||
)
|
||||
|
||||
// PutRevision appends once. Unknown terms are rejected; retired terms remain
|
||||
// usable when copying historical revisions. Editors decide whether to admit new
|
||||
// retired-term assignments. Link targets are validated by the application: they
|
||||
// can live in a different content/catalog store. A link grants no authority.
|
||||
func PutRevision(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64, a cms.Associations) error {
|
||||
if !cms.ValidID(scope) || revision < 1 || a.Validate(ref) != nil {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
for _, id := range a.Terms {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_terms WHERE scope=? AND id=?`, scope, id).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return cms.ErrNotFound
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_resources(scope,kind,id) VALUES(?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_cms_associations(scope,kind,id,revision,document) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID, revision, string(b))
|
||||
if err = oneRow(result, err); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range a.Terms {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_memberships(scope,kind,id,revision,term_id) VALUES(?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, target := range a.Links {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_links(scope,kind,id,revision,target_kind,target_id) VALUES(?,?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, target.Kind, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPublished selects an exact revision or zero to unpublish. Call inside the
|
||||
// same transaction as the application's publication transition and audit. Draft
|
||||
// saves do not call this function. Product availability is also checked by the
|
||||
// consuming application; publication is not entitlement or payment authority.
|
||||
func SetPublished(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64) error {
|
||||
if !cms.ValidID(scope) || ref.Validate() != nil || revision < 0 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
if revision > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, scope, ref.Kind, ref.ID, revision).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return cms.ErrNotFound
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_cms_resources SET published_revision=? WHERE scope=? AND kind=? AND id=?`, revision, scope, ref.Kind, ref.ID)
|
||||
return oneRow(result, err)
|
||||
}
|
||||
|
||||
func (r *Reader) Revision(ctx context.Context, ref cms.Reference, revision int64) (cms.Associations, error) {
|
||||
if ref.Validate() != nil || revision < 1 {
|
||||
return cms.Associations{}, cms.ErrInvalid
|
||||
}
|
||||
var raw string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT document FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, r.scope, ref.Kind, ref.ID, revision).Scan(&raw)
|
||||
if err != nil {
|
||||
return cms.Associations{}, notFound(err)
|
||||
}
|
||||
var a cms.Associations
|
||||
if len(raw) > 16384 || json.Unmarshal([]byte(raw), &a) != nil || a.Validate(ref) != nil {
|
||||
return cms.Associations{}, cms.ErrInvalid
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func (r *Reader) LatestRevision(ctx context.Context, ref cms.Reference) (int64, error) {
|
||||
if ref.Validate() != nil {
|
||||
return 0, cms.ErrInvalid
|
||||
}
|
||||
var revision sql.NullInt64
|
||||
err := r.db.QueryRowContext(ctx, `SELECT MAX(revision) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=?`, r.scope, ref.Kind, ref.ID).Scan(&revision)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !revision.Valid {
|
||||
return 0, cms.ErrNotFound
|
||||
}
|
||||
return revision.Int64, nil
|
||||
}
|
||||
func (r *Reader) PublishedRevision(ctx context.Context, ref cms.Reference) (int64, error) {
|
||||
if ref.Validate() != nil {
|
||||
return 0, cms.ErrInvalid
|
||||
}
|
||||
var revision int64
|
||||
err := r.db.QueryRowContext(ctx, `SELECT published_revision FROM gwf_cms_resources WHERE scope=? AND kind=? AND id=? AND published_revision>0`, r.scope, ref.Kind, ref.ID).Scan(&revision)
|
||||
return revision, notFound(err)
|
||||
}
|
||||
|
||||
// Members returns published members of an active term and taxonomy. Pagination
|
||||
// happens after publication filtering, with stable kind/ID cursors.
|
||||
func (r *Reader) Members(ctx context.Context, termID string, after *cms.Reference, limit int) (cms.Page, error) {
|
||||
if !cms.ValidID(termID) {
|
||||
return cms.Page{}, cms.ErrInvalid
|
||||
}
|
||||
query := `SELECT DISTINCT r.kind,r.id,r.published_revision FROM gwf_cms_memberships m JOIN gwf_cms_resources r ON r.scope=m.scope AND r.kind=m.kind AND r.id=m.id AND r.published_revision=m.revision JOIN gwf_cms_terms t ON t.scope=m.scope AND t.id=m.term_id JOIN gwf_cms_taxonomies x ON x.scope=t.scope AND x.id=t.taxonomy_id WHERE m.scope=? AND m.term_id=? AND r.published_revision>0 AND t.active=1 AND x.active=1`
|
||||
return r.page(ctx, query, []any{r.scope, termID}, after, limit)
|
||||
}
|
||||
|
||||
// Related returns both directions of explicit relationships between published
|
||||
// revisions. Shared taxonomy membership alone does not assert a relationship.
|
||||
func (r *Reader) Related(ctx context.Context, ref cms.Reference, after *cms.Reference, limit int) (cms.Page, error) {
|
||||
if ref.Validate() != nil {
|
||||
return cms.Page{}, cms.ErrInvalid
|
||||
}
|
||||
query := `WITH edges AS (
|
||||
SELECT l.target_kind AS kind,l.target_id AS id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.kind=? AND l.id=? AND s.published_revision>0
|
||||
UNION
|
||||
SELECT l.kind,l.id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.target_kind=? AND l.target_id=? AND s.published_revision>0
|
||||
) SELECT r.kind,r.id,r.published_revision FROM edges e JOIN gwf_cms_resources r ON r.kind=e.kind AND r.id=e.id WHERE r.scope=? AND r.published_revision>0 AND NOT(r.kind=? AND r.id=?) AND EXISTS(SELECT 1 FROM gwf_cms_resources origin WHERE origin.scope=? AND origin.kind=? AND origin.id=? AND origin.published_revision>0)`
|
||||
return r.page(ctx, query, []any{r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID}, after, limit)
|
||||
}
|
||||
|
||||
func (r *Reader) page(ctx context.Context, query string, args []any, after *cms.Reference, limit int) (cms.Page, error) {
|
||||
if limit < 1 || limit > 100 || (after != nil && after.Validate() != nil) {
|
||||
return cms.Page{}, cms.ErrInvalid
|
||||
}
|
||||
if after != nil {
|
||||
query += ` AND (r.kind>? OR (r.kind=? AND r.id>?))`
|
||||
args = append(args, after.Kind, after.Kind, after.ID)
|
||||
}
|
||||
query += ` ORDER BY r.kind,r.id LIMIT ?`
|
||||
args = append(args, limit+1)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return cms.Page{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
p := cms.Page{Items: []cms.ResourceVersion{}}
|
||||
for rows.Next() {
|
||||
var v cms.ResourceVersion
|
||||
if err := rows.Scan(&v.Kind, &v.ID, &v.Revision); err != nil {
|
||||
return cms.Page{}, err
|
||||
}
|
||||
p.Items = append(p.Items, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return cms.Page{}, err
|
||||
}
|
||||
if len(p.Items) > limit {
|
||||
p.Items = p.Items[:limit]
|
||||
ref := p.Items[limit-1].Reference
|
||||
p.Next = &ref
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
package cmssqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"gamertan.com/web/cms"
|
||||
"gamertan.com/web/cmssqlite"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
ctx := context.Background()
|
||||
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = cmssqlite.CreateSchema(ctx, tx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
const scope = "my-site"
|
||||
if err = cmssqlite.PutTaxonomy(ctx, tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Revision: 1, Active: true}, 0); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = cmssqlite.PutTerm(ctx, tx, scope, cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Revision: 1, Active: true}, 0); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ref := cms.Reference{Kind: "article", ID: "first-post"}
|
||||
// The application authorizes the writer and stores its content/audit in this
|
||||
// same transaction. Only publication advances the public association pointer.
|
||||
if err = cmssqlite.PutRevision(ctx, tx, scope, ref, 1, cms.Associations{Terms: []string{"go"}}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = cmssqlite.SetPublished(ctx, tx, scope, ref, 1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
reader, err := cmssqlite.New(db, scope)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
page, err := reader.Members(ctx, "go", nil, 20)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
fmt.Println(item.Kind, item.ID, item.Revision)
|
||||
}
|
||||
// Output: article first-post 1
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package cmssqlite stores cms values in an application's SQLite transaction.
|
||||
// Schema installation is explicit. Callers own database opening, migration
|
||||
// versions, authorization and audits. Every mutation must use a caller-owned
|
||||
// transaction, committing its domain change and audit together; never use a
|
||||
// pooled *sql.DB for multi-statement writes. Namespaces isolate application data.
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"gamertan.com/web/cms"
|
||||
)
|
||||
|
||||
type Queryer interface {
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
// Reader may use a database or read transaction. Writers below require *sql.Tx.
|
||||
type Reader struct {
|
||||
db Queryer
|
||||
scope string
|
||||
}
|
||||
|
||||
func New(db Queryer, scope string) (*Reader, error) {
|
||||
if db == nil || !cms.ValidID(scope) {
|
||||
return nil, cms.ErrInvalid
|
||||
}
|
||||
return &Reader{db: db, scope: scope}, nil
|
||||
}
|
||||
|
||||
// CreateSchema must be called from the application's explicit migration.
|
||||
// It never changes an existing publishing schema or starts a transaction.
|
||||
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_taxonomies (scope TEXT NOT NULL,id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,slug))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_terms (scope TEXT NOT NULL,id TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,taxonomy_id,slug),FOREIGN KEY(scope,taxonomy_id) REFERENCES gwf_cms_taxonomies(scope,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_term_slugs (scope TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,taxonomy_id,slug),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_resources (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,published_revision INTEGER NOT NULL DEFAULT 0 CHECK(published_revision>=0),PRIMARY KEY(scope,kind,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_associations (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),document TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision),FOREIGN KEY(scope,kind,id) REFERENCES gwf_cms_resources(scope,kind,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_memberships (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,term_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
|
||||
`CREATE INDEX IF NOT EXISTS gwf_cms_memberships_term ON gwf_cms_memberships(scope,term_id,kind,id,revision)`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_links (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,target_kind,target_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision))`,
|
||||
`CREATE INDEX IF NOT EXISTS gwf_cms_links_target ON gwf_cms_links(scope,target_kind,target_id,kind,id,revision)`,
|
||||
} {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/web/cms"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T) (*sql.DB, *Reader) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "cms.sqlite")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return CreateSchema(context.Background(), tx) })
|
||||
r, e := New(db, "merchant")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, scope := range []string{"merchant", "other"} {
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error {
|
||||
return PutTaxonomy(context.Background(), tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Active: true, Revision: 1}, 0)
|
||||
})
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error {
|
||||
return PutTerm(context.Background(), tx, "merchant", cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Active: true, Revision: 1}, 0)
|
||||
})
|
||||
return db, r
|
||||
}
|
||||
func mutate(t *testing.T, db *sql.DB, want error, fn func(*sql.Tx) error) {
|
||||
t.Helper()
|
||||
tx, e := db.BeginTx(context.Background(), nil)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err := fn(tx)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("mutation error %v, want %v", err, want)
|
||||
}
|
||||
if err == nil {
|
||||
if e = tx.Commit(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
func save(t *testing.T, db *sql.DB, ref cms.Reference, rev int64, a cms.Associations, publish bool) {
|
||||
t.Helper()
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error {
|
||||
if err := PutRevision(context.Background(), tx, "merchant", ref, rev, a); err != nil {
|
||||
return err
|
||||
}
|
||||
if publish {
|
||||
return SetPublished(context.Background(), tx, "merchant", ref, rev)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func TestPublicationRelationshipsAndHistory(t *testing.T) {
|
||||
db, r := fixture(t)
|
||||
ctx := context.Background()
|
||||
project := cms.Reference{Kind: "project", ID: "hime"}
|
||||
news := cms.Reference{Kind: "news", ID: "launch"}
|
||||
product := cms.Reference{Kind: "product", ID: "support"}
|
||||
save(t, db, project, 1, cms.Associations{Terms: []string{"go"}}, true)
|
||||
save(t, db, news, 1, cms.Associations{Terms: []string{"go"}, Links: []cms.Reference{project}}, false)
|
||||
p, e := r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatalf("draft leak: %+v %v", p, e)
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 1) })
|
||||
for _, ref := range []cms.Reference{project, news} {
|
||||
p, e = r.Related(ctx, ref, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatalf("reverse missing: %+v %v", p, e)
|
||||
}
|
||||
}
|
||||
save(t, db, product, 1, cms.Associations{}, true)
|
||||
save(t, db, news, 2, cms.Associations{Links: []cms.Reference{product}}, false)
|
||||
p, e = r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatal("draft replaced published graph", e)
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 2) })
|
||||
p, e = r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("stale published edge", e)
|
||||
}
|
||||
p, e = r.Related(ctx, product, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatal("missing new edge", e)
|
||||
}
|
||||
old, e := r.Revision(ctx, news, 1)
|
||||
if e != nil || old.Links[0] != project {
|
||||
t.Fatal("history changed", e)
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", product, 0) })
|
||||
p, e = r.Related(ctx, news, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("unpublished target leak", e)
|
||||
}
|
||||
p, e = r.Related(ctx, product, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("unpublished source discovery", e)
|
||||
}
|
||||
// Restoring an old association is a new revision, not an overwritten row.
|
||||
save(t, db, news, 3, old, true)
|
||||
p, e = r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 || p.Items[0].Revision != 3 {
|
||||
t.Fatal("restore", e)
|
||||
}
|
||||
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutRevision(ctx, tx, "merchant", news, 1, cms.Associations{}) })
|
||||
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 999) })
|
||||
}
|
||||
func TestTermRenameRetirementAndScope(t *testing.T) {
|
||||
db, r := fixture(t)
|
||||
ctx := context.Background()
|
||||
ref := cms.Reference{Kind: "writing", ID: "essay"}
|
||||
save(t, db, ref, 1, cms.Associations{Terms: []string{"go"}}, true)
|
||||
term, e := r.Term(ctx, "go")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
term.Slug = "golang"
|
||||
term.Name = "Go language"
|
||||
term.Revision = 2
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 1) })
|
||||
alias, e := r.TermBySlug(ctx, "topics", "go")
|
||||
if e != nil || alias.ID != term.ID || alias.Slug != "golang" {
|
||||
t.Fatal("alias", e)
|
||||
}
|
||||
stolen := cms.Term{ID: "stolen", TaxonomyID: "topics", Slug: "go", Name: "Other", Revision: 1, Active: true}
|
||||
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", stolen, 0) })
|
||||
duplicate := term
|
||||
duplicate.Revision = 1
|
||||
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", duplicate, 0) })
|
||||
other, _ := New(db, "other")
|
||||
if _, e = other.Term(ctx, "go"); !errors.Is(e, cms.ErrNotFound) {
|
||||
t.Fatal("cross scope term", e)
|
||||
}
|
||||
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error {
|
||||
return PutRevision(ctx, tx, "other", ref, 1, cms.Associations{Terms: []string{"go"}})
|
||||
})
|
||||
p, e := other.Members(ctx, "go", nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("scope leak", e)
|
||||
}
|
||||
p, e = r.Members(ctx, "go", nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatal("membership lost on rename", e)
|
||||
}
|
||||
term.Active = false
|
||||
term.Revision = 3
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 2) })
|
||||
p, e = r.Members(ctx, "go", nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("retired term discovery", e)
|
||||
}
|
||||
old, e := r.Revision(ctx, ref, 1)
|
||||
if e != nil || len(old.Terms) != 1 {
|
||||
t.Fatal("retirement rewrote history", e)
|
||||
}
|
||||
save(t, db, ref, 2, old, true)
|
||||
}
|
||||
func TestPaginationFiltersDraftsBeforeLimitAndRollback(t *testing.T) {
|
||||
db, r := fixture(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 35; i++ {
|
||||
save(t, db, cms.Reference{Kind: "news", ID: fmt.Sprintf("news-%02d", i)}, 1, cms.Associations{Terms: []string{"go"}}, i >= 30)
|
||||
}
|
||||
var after *cms.Reference
|
||||
var ids []string
|
||||
for {
|
||||
p, e := r.Members(ctx, "go", after, 2)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, v := range p.Items {
|
||||
ids = append(ids, v.ID)
|
||||
}
|
||||
if p.Next == nil {
|
||||
break
|
||||
}
|
||||
after = p.Next
|
||||
}
|
||||
if len(ids) != 5 || ids[0] != "news-30" || ids[4] != "news-34" {
|
||||
t.Fatal(ids)
|
||||
}
|
||||
tx, e := db.BeginTx(ctx, nil)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ref := cms.Reference{Kind: "project", ID: "rollback"}
|
||||
if e = PutRevision(ctx, tx, "merchant", ref, 1, cms.Associations{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = SetPublished(ctx, tx, "merchant", ref, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = tx.Rollback(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = r.PublishedRevision(ctx, ref); !errors.Is(e, cms.ErrNotFound) {
|
||||
t.Fatal("partial transaction", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"gamertan.com/web/cms"
|
||||
)
|
||||
|
||||
func notFound(err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return cms.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Reader) Taxonomy(ctx context.Context, id string) (cms.Taxonomy, error) {
|
||||
var v cms.Taxonomy
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
|
||||
return v, notFound(err)
|
||||
}
|
||||
func (r *Reader) Taxonomies(ctx context.Context) ([]cms.Taxonomy, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? ORDER BY slug LIMIT 101`, r.scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []cms.Taxonomy{}
|
||||
for rows.Next() {
|
||||
var v cms.Taxonomy
|
||||
if err := rows.Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
if len(values) > 100 {
|
||||
return nil, cms.ErrInvalid
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
func PutTaxonomy(ctx context.Context, tx *sql.Tx, scope string, v cms.Taxonomy, expected int64) error {
|
||||
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
var result sql.Result
|
||||
var err error
|
||||
if expected == 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_taxonomies WHERE scope=?`, scope).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= 100 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_taxonomies(scope,id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
|
||||
} else {
|
||||
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_taxonomies SET name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=? AND slug=?`, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected, v.Slug)
|
||||
}
|
||||
return oneRow(result, err)
|
||||
}
|
||||
func oneRow(result sql.Result, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return cms.ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *Reader) Term(ctx context.Context, id string) (cms.Term, error) {
|
||||
var v cms.Term
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
|
||||
return v, notFound(err)
|
||||
}
|
||||
|
||||
// Terms pages by immutable ID, including retired values for editors. The caller
|
||||
// filters public availability using both taxonomy and term Active fields.
|
||||
func (r *Reader) Terms(ctx context.Context, taxonomyID, after string, limit int) ([]cms.Term, error) {
|
||||
if !cms.ValidID(taxonomyID) || (after != "" && !cms.ValidID(after)) || limit < 1 || limit > 200 {
|
||||
return nil, cms.ErrInvalid
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND id>? ORDER BY id LIMIT ?`, r.scope, taxonomyID, after, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []cms.Term{}
|
||||
for rows.Next() {
|
||||
var v cms.Term
|
||||
if err := rows.Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
// TermBySlug resolves old term slugs to the current record. The caller redirects
|
||||
// to v.Slug after checking visibility; no private/retired term is published here.
|
||||
func (r *Reader) TermBySlug(ctx context.Context, taxonomyID, slug string) (cms.Term, error) {
|
||||
var id string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND slug=? UNION SELECT term_id FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? LIMIT 1`, r.scope, taxonomyID, slug, r.scope, taxonomyID, slug).Scan(&id)
|
||||
if err != nil {
|
||||
return cms.Term{}, notFound(err)
|
||||
}
|
||||
return r.Term(ctx, id)
|
||||
}
|
||||
func PutTerm(ctx context.Context, tx *sql.Tx, scope string, v cms.Term, expected int64) error {
|
||||
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
r, _ := New(tx, scope)
|
||||
tax, err := r.Taxonomy(ctx, v.TaxonomyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !tax.Active && v.Active {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
var old cms.Term
|
||||
if expected > 0 {
|
||||
old, err = r.Term(ctx, v.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if old.Revision != expected || old.TaxonomyID != v.TaxonomyID {
|
||||
return cms.ErrConflict
|
||||
}
|
||||
}
|
||||
occupied, err := r.TermBySlug(ctx, v.TaxonomyID, v.Slug)
|
||||
if err == nil && occupied.ID != v.ID {
|
||||
return cms.ErrConflict
|
||||
}
|
||||
if err != nil && !errors.Is(err, cms.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
var result sql.Result
|
||||
if expected == 0 {
|
||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_terms(scope,id,taxonomy_id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.TaxonomyID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
|
||||
} else {
|
||||
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_terms SET slug=?,name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=?`, v.Slug, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected)
|
||||
}
|
||||
if err = oneRow(result, err); err != nil {
|
||||
return err
|
||||
}
|
||||
if expected > 0 && old.Slug != v.Slug {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? AND term_id=?`, scope, v.TaxonomyID, v.Slug, v.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_term_slugs(scope,taxonomy_id,slug,term_id) VALUES(?,?,?,?)`, scope, v.TaxonomyID, old.Slug, v.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -28,6 +28,8 @@
|
||||
// optional no-CGO SQLite adapter.
|
||||
// - [organizations] and [access] model organizations, teams, invitations,
|
||||
// scoped roles, and audited temporary access.
|
||||
// - [cms] and [cmssqlite] add revision-aware taxonomies and editorial links
|
||||
// alongside application-owned content and coded templates.
|
||||
// - [abuse] applies application-classified request-abuse decisions.
|
||||
// - [analytics] creates bounded, disposable projections from requestlog
|
||||
// records without becoming a telemetry service.
|
||||
@@ -58,6 +60,8 @@
|
||||
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
|
||||
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
|
||||
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn
|
||||
// [cms]: https://pkg.go.dev/gamertan.com/web/cms
|
||||
// [cmssqlite]: https://pkg.go.dev/gamertan.com/web/cmssqlite
|
||||
// [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
|
||||
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
|
||||
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||
|
||||
# Classification without a page builder
|
||||
|
||||
`cms` defines small editorial values. `cmssqlite` stores them in caller-owned
|
||||
SQLite transactions. Neither package owns your content, router, templates,
|
||||
permissions or billing model. Use them alongside typed Go records and coded
|
||||
templates, not as a database-defined application builder.
|
||||
|
||||
## Two distinct relationships
|
||||
|
||||
- A **taxonomy** names a flat vocabulary, such as Categories or Topics. A **term**
|
||||
has a stable ID, editable name/description and an address. Renaming its address
|
||||
preserves aliases; retiring it hides discovery without rewriting old revisions.
|
||||
- An **explicit link** joins two `{kind, id}` references. `Related` reads that
|
||||
single edge in either direction. Sharing a term alone does not assert a link.
|
||||
|
||||
References contain no titles, URLs, application data or authorization. Resolve
|
||||
each public result through the owning repository using its current published
|
||||
revision and availability. Never render the latest draft merely because an older
|
||||
revision is published. Products may have additional availability/approval rules;
|
||||
editorial links do not bypass them or change prices, entitlements or purchases.
|
||||
|
||||
## Transactions and publication
|
||||
|
||||
Call `CreateSchema(ctx, tx)` during an explicit application migration. It creates
|
||||
the `gwf_cms_*` tables, independently of the authentication adapter's schema.
|
||||
There is no automatic migration, connection, worker or request middleware.
|
||||
Enable foreign keys on every connection and use the application's existing
|
||||
SQLite write discipline. Keep backups and schema compatibility in that owner.
|
||||
|
||||
Every reader/writer takes a validated namespace. A namespace separates data; it
|
||||
does not authorize the caller. Check permissions before reads and inside the
|
||||
application's mutation boundary where concurrent revocation matters.
|
||||
|
||||
Within the same transaction as your content revision and audit:
|
||||
|
||||
1. `PutRevision` stores the exact revision's immutable term/link selections.
|
||||
2. For publication, `SetPublished` points at that revision. Zero unpublishes.
|
||||
3. Commit content, associations, publication and audit together.
|
||||
|
||||
Saving a draft does not advance publication. Restore by copying the selected
|
||||
historical association document into a new revision, then publish separately.
|
||||
Terms must exist in the namespace; retired terms remain valid historical values.
|
||||
Applications decide which retired selections may be retained in new edits.
|
||||
External targets can be indexed with an empty published snapshot, but their
|
||||
owning module still determines whether a public link is available.
|
||||
|
||||
Taxonomy/term writes use expected revisions (zero for creation). Keep immutable
|
||||
IDs across name changes. Taxonomy addresses are fixed after creation; term
|
||||
addresses retain redirect history. A collision or stale revision returns
|
||||
`cms.ErrConflict`, not a successful overwrite. Transactions must be rolled back
|
||||
after any mutation error, including a later application audit failure.
|
||||
|
||||
## Bounds and discovery
|
||||
|
||||
- Each snapshot accepts at most 24 distinct terms and 16 distinct links, without
|
||||
self-links. Validation rejects invalid IDs, control characters and duplicate
|
||||
selections. Text fields have explicit byte bounds; names are not HTML.
|
||||
- A namespace has at most 100 taxonomies. `Terms` pages by stable term ID, at
|
||||
most 200 results per request. Applications should choose their own overall
|
||||
editor limits and search UI rather than loading an unbounded catalog.
|
||||
- `Members` and `Related` return at most 100 published references per page,
|
||||
ordered by kind/ID. Pass `Next` for the following page. Publication filtering
|
||||
happens before pagination; never use a mutable title as a cursor.
|
||||
- Owning-module visibility can filter further. Continue fetching bounded index
|
||||
pages to fill a visible page and construct a cursor from the last visible
|
||||
item; do not expose private titles or identifiers through error messages.
|
||||
|
||||
The package tests use real SQLite transactions, including WAL, race execution,
|
||||
scope isolation, delayed publication, reverse discovery, aliases, retirement,
|
||||
pagination and rollback. Consumer tests still need to prove actual HTTP/API
|
||||
permissions, public visibility, escaping, editor usability and application data
|
||||
preservation. These packages do not claim to provide an entire CMS.
|
||||
@@ -8,6 +8,15 @@ application concern belongs in the shared module.
|
||||
|
||||
## Gamertan accounts and commerce
|
||||
|
||||
- Typed content needs shared categories and relationships without becoming a
|
||||
page builder. The `cms`/`cmssqlite` boundary separates application-owned bodies
|
||||
and products from immutable editorial associations. Keeping associations and
|
||||
publication in the content transaction prevents a draft save from changing
|
||||
public reverse links. Stable references avoid rewriting purchases on a project
|
||||
rename. Consumer HTTP tests caught compiled article snapshots shadowing CMS
|
||||
revisions and catalog pickers showing newer draft titles; those are application
|
||||
routing/visibility responsibilities, not extra policy in this package.
|
||||
|
||||
- Personal identity editing is not instance administration. `OwnProfileRepository`
|
||||
derives self-access from the active session; `ProfileEdit` is a trusted internal
|
||||
command, never a browser request model. SQLite schema 11 adds a monotonic
|
||||
|
||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
||||
module checksum:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.26
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.27
|
||||
go mod verify
|
||||
```
|
||||
|
||||
|
||||
@@ -68,6 +68,13 @@ authwebauthn/service_test.go
|
||||
authwebauthn/types.go
|
||||
bootstrap/bootstrap.go
|
||||
bootstrap/bootstrap_test.go
|
||||
cms/cms.go
|
||||
cms/cms_test.go
|
||||
cmssqlite/schema.go
|
||||
cmssqlite/terms.go
|
||||
cmssqlite/associations.go
|
||||
cmssqlite/store_test.go
|
||||
cmssqlite/example_test.go
|
||||
media/media.go
|
||||
media/media_test.go
|
||||
medialocal/store.go
|
||||
@@ -76,6 +83,7 @@ internal/webauthnvendored/
|
||||
docs/ADOPTION.md
|
||||
docs/ARCHITECTURE.md
|
||||
docs/DEPENDENCIES.md
|
||||
docs/CMS.md
|
||||
docs/DOGFOOD.md
|
||||
docs/GETTING_STARTED.md
|
||||
docs/MODULES.md
|
||||
|
||||
Reference in New Issue
Block a user