// 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 }