Add revision-aware CMS taxonomies and relationships
This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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
|
## v0.1.0-preview.26 — 2026-09-05
|
||||||
|
|
||||||
- Add optional self-profile readers and revision-checked username/display-name
|
- 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
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
packages you import.
|
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.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -44,6 +44,7 @@ packages you import.
|
|||||||
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
|
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
|
||||||
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
| 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) |
|
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
||||||
| Organization-scoped roles and temporary access | [`access`](access) |
|
| Organization-scoped roles and temporary access | [`access`](access) |
|
||||||
| Application-classified request abuse | [`abuse`](abuse) |
|
| 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:
|
Pin the preview in an application module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.26
|
go get gamertan.com/web@v0.1.0-preview.27
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may name the first package it intends to adopt:
|
An application may name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```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
|
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.
|
// optional no-CGO SQLite adapter.
|
||||||
// - [organizations] and [access] model organizations, teams, invitations,
|
// - [organizations] and [access] model organizations, teams, invitations,
|
||||||
// scoped roles, and audited temporary access.
|
// 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.
|
// - [abuse] applies application-classified request-abuse decisions.
|
||||||
// - [analytics] creates bounded, disposable projections from requestlog
|
// - [analytics] creates bounded, disposable projections from requestlog
|
||||||
// records without becoming a telemetry service.
|
// records without becoming a telemetry service.
|
||||||
@@ -58,6 +60,8 @@
|
|||||||
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
|
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
|
||||||
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
|
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
|
||||||
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn
|
// [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
|
// [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
|
||||||
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
|
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
|
||||||
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
|
// [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
|
## 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`
|
- Personal identity editing is not instance administration. `OwnProfileRepository`
|
||||||
derives self-access from the active session; `ProfileEdit` is a trusted internal
|
derives self-access from the active session; `ProfileEdit` is a trusted internal
|
||||||
command, never a browser request model. SQLite schema 11 adds a monotonic
|
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:
|
module checksum:
|
||||||
|
|
||||||
```bash
|
```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
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ authwebauthn/service_test.go
|
|||||||
authwebauthn/types.go
|
authwebauthn/types.go
|
||||||
bootstrap/bootstrap.go
|
bootstrap/bootstrap.go
|
||||||
bootstrap/bootstrap_test.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.go
|
||||||
media/media_test.go
|
media/media_test.go
|
||||||
medialocal/store.go
|
medialocal/store.go
|
||||||
@@ -76,6 +83,7 @@ internal/webauthnvendored/
|
|||||||
docs/ADOPTION.md
|
docs/ADOPTION.md
|
||||||
docs/ARCHITECTURE.md
|
docs/ARCHITECTURE.md
|
||||||
docs/DEPENDENCIES.md
|
docs/DEPENDENCIES.md
|
||||||
|
docs/CMS.md
|
||||||
docs/DOGFOOD.md
|
docs/DOGFOOD.md
|
||||||
docs/GETTING_STARTED.md
|
docs/GETTING_STARTED.md
|
||||||
docs/MODULES.md
|
docs/MODULES.md
|
||||||
|
|||||||
Reference in New Issue
Block a user