Full implementation of the write adapter as an HTTP client over mBrian's /api/projax/* surface (final spec, m/mBrian#73 issuecomment-10720): - do(): JSON request/response plumbing; bearer auth from the configured token; fails closed (503-style) on empty URL/token rather than sending an empty Bearer; maps non-2xx to *APIError (401/403/404/500/503) and wraps 404 as ErrNotFound. {error} message extracted from the body. - Create: POST node (mints a fresh projax_origin uuid via crypto/rand — no uuid dep), writes child_of parent edges, materialises via the reader so the returned Item.ID is the live mBrian uuid + derived path. This is the create-child round-trip the slice-B half-flip broke. - Update: PATCH node fields + syncParents() edge diff; read-back. - Reparent/AddParent: child_of edge diff / idempotent add. - SetPublic: read-modify-write the full public bundle (PATCH replaces the whole projax.public object on shallow-merge). - SoftDeleteCascade: projax-side descendant resolution via the reader's derived paths, then per-node soft-delete (HTTP API is single-node). - AddLink/AddLinkDated/DeleteLink: projax-* self-edges; edge metadata shaped so the reader's linkFromEdge round-trips (typed payload + projax_rel + ref_id + event_date + note). DeleteLink resolves the edge's (source,target,rel) from its id, with a guard that refuses to delete when >1 edge shares the tuple. Documented four API gaps inline + in the file header for head to reconcile before relying on them (verified current data hits none of the active ones): G1 edge identity collapses multiple same-ref_type links per item (latent — 0 in current data); G2 POST /edges has no note field; G3 PATCH can't set pinned/archived node columns (captured in metadata.projax, pending reader fallback); G4 no top-level metadata passthrough on create. Build + vet green. Writer is wired but unreachable until main.go flips the backend (next commit) — no behaviour change yet.
706 lines
25 KiB
Go
706 lines
25 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Phase 6 Slice C — MBrianWriter is the live write-path adapter against
|
|
// mBrian's scoped HTTP write API. Per mbrian/head's mechanism call
|
|
// (option (c), 2026-06-01, m/mBrian#73): projax writes go through an HTTP
|
|
// API that reuses mBrian's db.ts slug-generation / collision-resolution /
|
|
// singleton logic, NOT raw SQL and NOT SECURITY DEFINER functions —
|
|
// reimplementing that logic in SQL would drift.
|
|
//
|
|
// So this is an HTTP CLIENT, not a pgx writer. Reads stay direct-DB
|
|
// (MBrianReader, slice B). The asymmetry is deliberate: reads are pure
|
|
// projections; writes must funnel through db.ts so projax-created nodes
|
|
// are byte-identical to UI / MCP / migration-script nodes.
|
|
//
|
|
// The scoped API enforces projax-ownership server-side (every node it
|
|
// touches must carry metadata.projax_origin) so a projax bug can never
|
|
// corrupt non-projax mBrian nodes (m's journal / contacts / health).
|
|
//
|
|
// Final surface (m/mBrian#73 issuecomment-10720):
|
|
// POST /api/projax/nodes {title, content_md?, aliases?, mai_managed?, projax_origin, projax:{...}} → 201 {id, slug}
|
|
// PATCH /api/projax/nodes/{id} {title?, content_md?, aliases?, projax?:{...partial}} → 200 {id, slug}
|
|
// DELETE /api/projax/nodes/{id} → 204 (soft-delete)
|
|
// POST /api/projax/edges {source, target, rel, metadata?} → 201|200 {id}
|
|
// DELETE /api/projax/edges {source, target, rel} → 204
|
|
// Bearer-token auth; base URL + token from PROJAX_MBRIAN_API_URL /
|
|
// PROJAX_MBRIAN_API_TOKEN (projax-side env names; the mBrian server reads
|
|
// the same secret under PROJAX_WRITE_TOKEN). Errors: 400 malformed,
|
|
// 401 bad token, 403 not projax-owned, 404 missing, 500 db.ts error,
|
|
// 503 token not configured server-side (fail-closed: backend not ready).
|
|
//
|
|
// KNOWN API GAPS flagged to head (m/mBrian#73), reconcile before relying
|
|
// on them in production:
|
|
// G1 (latent) — edges key on (source,target,rel) only. POST is
|
|
// idempotent on that tuple and DELETE removes by it, so an item
|
|
// cannot hold two links of the same ref_type (e.g. two dated docs,
|
|
// multiple gitea-issues, multiple calendars). Current data has zero
|
|
// such cases (verified), so this is latent, not an active break.
|
|
// G2 — POST /edges has no `note` field; AddLinkDated's note is captured
|
|
// in edge metadata.note instead of the edge.note column the reader
|
|
// reads. Notes added post-cutover won't surface as ItemLink.Note
|
|
// until the reader also reads metadata.note (or the API gains note).
|
|
// G3 (active) — PATCH exposes no pinned/archived; the reader reads those
|
|
// from node columns. This writer captures them in metadata.projax.
|
|
// {pinned,archived} so intent isn't lost, but a star/archive toggle
|
|
// won't round-trip until the reader falls back to metadata.projax
|
|
// (recommended — keeps pinned/archived alongside status/tags) or the
|
|
// API exposes the columns.
|
|
// G4 (minor) — Create has no arbitrary top-level metadata passthrough;
|
|
// CreateInput.Metadata keys outside the projax bundle are dropped
|
|
// (no current caller sets them).
|
|
|
|
// MBrianWriter satisfies store.ItemWriter by calling mBrian's scoped
|
|
// /api/projax/* HTTP write API. It holds a direct pool for the narrow
|
|
// read-backs the write path needs (materialising the created/updated
|
|
// Item, resolving an edge's source/target/rel from its id before a
|
|
// DELETE, diffing child_of edges on reparent) — reads stay direct-DB.
|
|
type MBrianWriter struct {
|
|
baseURL string
|
|
token string
|
|
http *http.Client
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewMBrianWriter wires the writer to mBrian's HTTP write API. baseURL is
|
|
// the scheme+host (e.g. https://mbrian.x.msbls.de); token is the shared
|
|
// bearer. pool is the same msupabase pool the reader uses, for the
|
|
// read-backs.
|
|
func NewMBrianWriter(baseURL, token string, pool *pgxpool.Pool) *MBrianWriter {
|
|
return &MBrianWriter{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
http: &http.Client{Timeout: 15 * time.Second},
|
|
pool: pool,
|
|
}
|
|
}
|
|
|
|
// Compile-time witness: MBrianWriter satisfies ItemWriter.
|
|
var _ ItemWriter = (*MBrianWriter)(nil)
|
|
|
|
// reader builds a read adapter over the same pool for write-path
|
|
// read-backs (materialise created/updated items, etc.).
|
|
func (w *MBrianWriter) reader() *MBrianReader { return NewMBrianReader(w.pool) }
|
|
|
|
// ====================================================================
|
|
// HTTP plumbing
|
|
// ====================================================================
|
|
|
|
// APIError is the typed error returned for any non-2xx response from the
|
|
// mBrian write API, carrying the HTTP status + server message so handlers
|
|
// can render it without substring-matching.
|
|
type APIError struct {
|
|
Status int
|
|
Message string
|
|
Op string // e.g. "POST /api/projax/nodes"
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
msg := e.Message
|
|
if msg == "" {
|
|
msg = http.StatusText(e.Status)
|
|
}
|
|
switch e.Status {
|
|
case http.StatusUnauthorized:
|
|
return fmt.Sprintf("mBrian write API %s: unauthorized (token missing or wrong)", e.Op)
|
|
case http.StatusForbidden:
|
|
return fmt.Sprintf("mBrian write API %s: target node is not projax-owned", e.Op)
|
|
case http.StatusServiceUnavailable:
|
|
return fmt.Sprintf("mBrian write API %s: write backend not ready (token not configured)", e.Op)
|
|
}
|
|
return fmt.Sprintf("mBrian write API %s: %d %s", e.Op, e.Status, msg)
|
|
}
|
|
|
|
// do issues one JSON request to the mBrian write API and decodes a JSON
|
|
// response into out (when out != nil and the response carries a body). A
|
|
// non-2xx status becomes an *APIError; a 404 additionally wraps
|
|
// ErrNotFound so callers branching on it keep working.
|
|
func (w *MBrianWriter) do(ctx context.Context, method, path string, body, out any) error {
|
|
op := method + " " + path
|
|
if w.baseURL == "" {
|
|
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_URL not set", Op: op}
|
|
}
|
|
if w.token == "" {
|
|
// Never send an empty Bearer — fail closed and legibly.
|
|
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_TOKEN not set", Op: op}
|
|
}
|
|
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
buf, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: marshal body: %w", op, err)
|
|
}
|
|
reqBody = bytes.NewReader(buf)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, w.baseURL+path, reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: build request: %w", op, err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+w.token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := w.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", op, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
apiErr := &APIError{Status: resp.StatusCode, Message: extractAPIMessage(raw), Op: op}
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return fmt.Errorf("%w: %w", ErrNotFound, apiErr)
|
|
}
|
|
return apiErr
|
|
}
|
|
if out != nil && len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return fmt.Errorf("%s: decode response: %w", op, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractAPIMessage pulls the {"error": "..."} message the API returns on
|
|
// every non-2xx; falls back to the raw body when it isn't that shape.
|
|
func extractAPIMessage(raw []byte) string {
|
|
var env struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if json.Unmarshal(raw, &env) == nil && env.Error != "" {
|
|
return env.Error
|
|
}
|
|
return strings.TrimSpace(string(raw))
|
|
}
|
|
|
|
// nodeWriteResponse is the {id, slug} shape POST/PATCH /nodes return.
|
|
type nodeWriteResponse struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
// ====================================================================
|
|
// Item writes
|
|
// ====================================================================
|
|
|
|
// Create POSTs a new projax node, then writes its child_of parent edges,
|
|
// then materialises the result via the reader so the returned Item.ID is
|
|
// the live mBrian uuid + its derived path — the exact round-trip the
|
|
// slice-B half-flip broke (create-child against an mBrian-read parent).
|
|
func (w *MBrianWriter) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
|
if len(in.Kind) == 0 {
|
|
return nil, errors.New("kind required")
|
|
}
|
|
if strings.TrimSpace(in.Title) == "" {
|
|
return nil, errors.New("title required")
|
|
}
|
|
// New (non-migrated) nodes need a non-empty projax_origin so the
|
|
// server's ownership gate accepts later PATCH/DELETE/edge ops. Mint a
|
|
// fresh uuid — the audit marker for projax-born nodes.
|
|
origin, err := newUUIDv4()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mint projax_origin: %w", err)
|
|
}
|
|
body := map[string]any{
|
|
"title": in.Title,
|
|
"content_md": in.ContentMD,
|
|
"mai_managed": containsString(in.Kind, "mai-managed"),
|
|
"projax_origin": origin,
|
|
"projax": projaxBundleForCreate(in),
|
|
}
|
|
var resp nodeWriteResponse
|
|
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
// Parent links are child_of edges. Idempotent POST per parent.
|
|
for _, pid := range dedupe(in.ParentIDs) {
|
|
if pid == "" {
|
|
continue
|
|
}
|
|
if err := w.postEdge(ctx, resp.ID, pid, "child_of", nil); err != nil {
|
|
return nil, fmt.Errorf("create %s: add parent %s: %w", resp.ID, pid, err)
|
|
}
|
|
}
|
|
return w.reader().GetByID(ctx, resp.ID)
|
|
}
|
|
|
|
// Update PATCHes the node's editable fields, then reconciles its child_of
|
|
// edges to match in.ParentIDs (the detail-edit form ships parent_ids in
|
|
// the same submit), then materialises the updated Item via the reader.
|
|
func (w *MBrianWriter) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
|
body := map[string]any{
|
|
"title": in.Title,
|
|
"content_md": in.ContentMD,
|
|
"projax": projaxBundleForUpdate(in),
|
|
}
|
|
var resp nodeWriteResponse
|
|
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := w.syncParents(ctx, id, in.ParentIDs); err != nil {
|
|
return nil, fmt.Errorf("update %s: sync parents: %w", id, err)
|
|
}
|
|
return w.reader().GetByID(ctx, id)
|
|
}
|
|
|
|
// Reparent replaces the node's child_of parent set entirely.
|
|
func (w *MBrianWriter) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
|
|
if err := w.syncParents(ctx, id, parentIDs); err != nil {
|
|
return nil, fmt.Errorf("reparent %s: %w", id, err)
|
|
}
|
|
return w.reader().GetByID(ctx, id)
|
|
}
|
|
|
|
// AddParent appends one child_of edge without disturbing existing ones.
|
|
// POST /edges is idempotent, so a duplicate parent is a no-op.
|
|
func (w *MBrianWriter) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
|
|
if err := w.postEdge(ctx, id, parentID, "child_of", nil); err != nil {
|
|
return nil, fmt.Errorf("add parent %s→%s: %w", id, parentID, err)
|
|
}
|
|
return w.reader().GetByID(ctx, id)
|
|
}
|
|
|
|
// SetPublic flips public.enabled on each id. public lives nested under
|
|
// metadata.projax.public and PATCH replaces the whole `public` sub-object
|
|
// (shallow merge is at the projax-key level), so we read each item and
|
|
// re-send its full public bundle with enabled toggled — never clobber the
|
|
// description / urls / screenshots.
|
|
func (w *MBrianWriter) SetPublic(ctx context.Context, ids []string, public bool) error {
|
|
rd := w.reader()
|
|
for _, id := range ids {
|
|
it, err := rd.GetByID(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("set public: load %s: %w", id, err)
|
|
}
|
|
body := map[string]any{
|
|
"projax": map[string]any{
|
|
"public": publicBundle(public, it.PublicDescription, it.PublicLiveURL, it.PublicSourceURL, it.PublicScreenshots),
|
|
},
|
|
}
|
|
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetPinned flips pinned on each id. GAP G3: pinned is a node column the
|
|
// PATCH surface doesn't expose, so we capture it in metadata.projax.pinned
|
|
// (best-effort, won't round-trip through the column-reading reader until
|
|
// that's reconciled — see the file header).
|
|
func (w *MBrianWriter) SetPinned(ctx context.Context, ids []string, pinned bool) error {
|
|
for _, id := range ids {
|
|
body := map[string]any{
|
|
"projax": map[string]any{"pinned": pinned},
|
|
}
|
|
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SoftDelete soft-deletes one node (sets deleted_at; edges preserved).
|
|
func (w *MBrianWriter) SoftDelete(ctx context.Context, id string) error {
|
|
return w.do(ctx, http.MethodDelete, "/api/projax/nodes/"+id, nil, nil)
|
|
}
|
|
|
|
// SoftDeleteCascade soft-deletes the node and, when cascade is true, every
|
|
// descendant. Without cascade it refuses if any live descendant exists —
|
|
// same contract as *Store.SoftDeleteCascade. Descendants are resolved
|
|
// projax-side via the reader's derived paths (cycle-safe, depth-capped),
|
|
// then soft-deleted one node at a time (the HTTP API is single-node).
|
|
func (w *MBrianWriter) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error {
|
|
rd := w.reader()
|
|
it, err := rd.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
all, err := rd.ListAll(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
descendants := descendantsOf(it, all)
|
|
if len(descendants) > 0 && !cascade {
|
|
return ErrHasLiveChildren
|
|
}
|
|
for _, d := range descendants {
|
|
if err := w.SoftDelete(ctx, d.ID); err != nil {
|
|
return fmt.Errorf("cascade soft-delete %s: %w", d.ID, err)
|
|
}
|
|
}
|
|
return w.SoftDelete(ctx, id)
|
|
}
|
|
|
|
// descendantsOf returns every item in all whose path sits strictly under
|
|
// one of target's primary paths, plus any direct child naming target as a
|
|
// parent. Mirrors *Store.SoftDeleteCascade's predicate without SQL.
|
|
func descendantsOf(target *Item, all []*Item) []*Item {
|
|
prefixes := make([]string, 0, len(target.Paths))
|
|
for _, p := range target.Paths {
|
|
prefixes = append(prefixes, p+".")
|
|
}
|
|
out := []*Item{}
|
|
for _, it := range all {
|
|
if it.ID == target.ID {
|
|
continue
|
|
}
|
|
hit := containsString(it.ParentIDs, target.ID)
|
|
if !hit {
|
|
for _, p := range it.Paths {
|
|
for _, pfx := range prefixes {
|
|
if strings.HasPrefix(p, pfx) {
|
|
hit = true
|
|
break
|
|
}
|
|
}
|
|
if hit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if hit {
|
|
out = append(out, it)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ====================================================================
|
|
// Link writes — projax-* self-edges
|
|
// ====================================================================
|
|
|
|
// AddLink writes an external-reference self-edge (source==target==item,
|
|
// rel='projax-<refType>') carrying the typed payload in edge metadata so
|
|
// the reader's linkFromEdge can decode it. GAP G1: POST /edges is
|
|
// idempotent on (source,target,rel), so a second link of the same
|
|
// ref_type on one item returns the existing edge instead of a new one.
|
|
func (w *MBrianWriter) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
|
|
return w.addLink(ctx, itemID, refType, refID, rel, nil, nil, metadata)
|
|
}
|
|
|
|
// AddLinkDated is AddLink with an event_date + explicit note. GAP G2: the
|
|
// note rides in edge metadata (the API has no note field), so it won't
|
|
// surface as ItemLink.Note via the column-reading reader yet.
|
|
func (w *MBrianWriter) AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
|
|
return w.addLink(ctx, itemID, refType, refID, rel, note, eventDate, metadata)
|
|
}
|
|
|
|
func (w *MBrianWriter) addLink(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
|
|
if rel == "" {
|
|
rel = "contains"
|
|
}
|
|
meta := edgeMetadataForLink(refType, refID, rel, note, eventDate, metadata)
|
|
edgeID, err := w.postEdgeReturningID(ctx, itemID, itemID, "projax-"+refType, meta)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add link %s on %s: %w", refType, itemID, err)
|
|
}
|
|
l := &ItemLink{
|
|
ID: edgeID,
|
|
ItemID: itemID,
|
|
RefType: refType,
|
|
RefID: refID,
|
|
Rel: rel,
|
|
Note: note,
|
|
Metadata: map[string]any{},
|
|
EventDate: eventDate,
|
|
CreatedAt: time.Time{}, // server-assigned; re-read via the reader if needed
|
|
}
|
|
maps.Copy(l.Metadata, metadata)
|
|
return l, nil
|
|
}
|
|
|
|
// DeleteLink removes a link self-edge by its edge id. The HTTP API deletes
|
|
// by (source,target,rel), so we resolve those from the edge id via a
|
|
// direct read-back. GAP G1 guard: if more than one edge shares that
|
|
// (source,target,rel) — only possible for >1 same-ref_type links on an
|
|
// item, which the current data never has — we refuse rather than delete
|
|
// all of them, surfacing the limitation instead of losing siblings.
|
|
func (w *MBrianWriter) DeleteLink(ctx context.Context, id string) error {
|
|
var source, target, rel string
|
|
err := w.pool.QueryRow(ctx,
|
|
`SELECT source_id::text, target_id::text, rel FROM mbrian.edges WHERE id = $1`, id).
|
|
Scan(&source, &target, &rel)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "no rows") {
|
|
return fmt.Errorf("delete link %s: %w", id, ErrNotFound)
|
|
}
|
|
return fmt.Errorf("delete link %s: resolve edge: %w", id, err)
|
|
}
|
|
var siblings int
|
|
if err := w.pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM mbrian.edges WHERE source_id = $1 AND target_id = $2 AND rel = $3`,
|
|
source, target, rel).Scan(&siblings); err != nil {
|
|
return fmt.Errorf("delete link %s: count siblings: %w", id, err)
|
|
}
|
|
if siblings > 1 {
|
|
return fmt.Errorf("delete link %s: %d edges share (source,target,rel=%s); the mBrian edge API cannot delete a single one (gap G1)", id, siblings, rel)
|
|
}
|
|
return w.deleteEdge(ctx, source, target, rel)
|
|
}
|
|
|
|
// ====================================================================
|
|
// Edge helpers
|
|
// ====================================================================
|
|
|
|
func (w *MBrianWriter) postEdge(ctx context.Context, source, target, rel string, metadata map[string]any) error {
|
|
_, err := w.postEdgeReturningID(ctx, source, target, rel, metadata)
|
|
return err
|
|
}
|
|
|
|
func (w *MBrianWriter) postEdgeReturningID(ctx context.Context, source, target, rel string, metadata map[string]any) (string, error) {
|
|
body := map[string]any{"source": source, "target": target, "rel": rel}
|
|
if metadata != nil {
|
|
body["metadata"] = metadata
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := w.do(ctx, http.MethodPost, "/api/projax/edges", body, &resp); err != nil {
|
|
return "", err
|
|
}
|
|
return resp.ID, nil
|
|
}
|
|
|
|
func (w *MBrianWriter) deleteEdge(ctx context.Context, source, target, rel string) error {
|
|
body := map[string]any{"source": source, "target": target, "rel": rel}
|
|
return w.do(ctx, http.MethodDelete, "/api/projax/edges", body, nil)
|
|
}
|
|
|
|
// childOfTargets returns the current child_of parent ids for a node,
|
|
// read direct-DB (a write-path read-back, scoped to projax-managed nodes).
|
|
func (w *MBrianWriter) childOfTargets(ctx context.Context, id string) ([]string, error) {
|
|
rows, err := w.pool.Query(ctx,
|
|
`SELECT target_id::text FROM mbrian.edges WHERE source_id = $1 AND rel = 'child_of'`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var t string
|
|
if err := rows.Scan(&t); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// syncParents diffs the node's current child_of edges against the desired
|
|
// parent set, deleting removed edges and adding new ones. Reparent and the
|
|
// parent-changing part of Update both route through here.
|
|
func (w *MBrianWriter) syncParents(ctx context.Context, id string, desired []string) error {
|
|
want := map[string]bool{}
|
|
for _, p := range dedupe(desired) {
|
|
if p != "" {
|
|
want[p] = true
|
|
}
|
|
}
|
|
current, err := w.childOfTargets(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
have := map[string]bool{}
|
|
for _, p := range current {
|
|
have[p] = true
|
|
}
|
|
for p := range have {
|
|
if !want[p] {
|
|
if err := w.deleteEdge(ctx, id, p, "child_of"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for p := range want {
|
|
if !have[p] {
|
|
if err := w.postEdge(ctx, id, p, "child_of", nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ====================================================================
|
|
// Payload shaping
|
|
// ====================================================================
|
|
|
|
// projaxBundleForCreate builds the metadata.projax object for a new node.
|
|
// The server stores it verbatim under metadata.projax. pinned is captured
|
|
// here per GAP G3.
|
|
func projaxBundleForCreate(in CreateInput) map[string]any {
|
|
kind := "project"
|
|
if containsString(in.Kind, "area") {
|
|
kind = "area"
|
|
}
|
|
status := in.Status
|
|
if status == "" {
|
|
status = "active"
|
|
}
|
|
b := map[string]any{
|
|
"kind": kind,
|
|
"status": status,
|
|
"tags": orEmpty(in.Tags),
|
|
"management": orEmpty(in.Management),
|
|
"public": map[string]any{},
|
|
"timeline_exclude": []string{},
|
|
"start_time": timePtrToJSON(in.StartTime),
|
|
"end_time": timePtrToJSON(in.EndTime),
|
|
}
|
|
if in.Pinned {
|
|
b["pinned"] = true
|
|
}
|
|
return b
|
|
}
|
|
|
|
// projaxBundleForUpdate builds the partial metadata.projax object for a
|
|
// PATCH. PATCH shallow-merges these keys into the existing projax bundle,
|
|
// so we send the full set the edit form owns (status/tags/management/
|
|
// public/timeline_exclude/start/end) + pinned/archived (GAP G3).
|
|
func projaxBundleForUpdate(in UpdateInput) map[string]any {
|
|
return map[string]any{
|
|
"status": in.Status,
|
|
"tags": orEmpty(in.Tags),
|
|
"management": orEmpty(in.Management),
|
|
"public": publicBundle(in.Public, in.PublicDescription, in.PublicLiveURL, in.PublicSourceURL, in.PublicScreenshots),
|
|
"timeline_exclude": orEmpty(in.TimelineExclude),
|
|
"start_time": timePtrToJSON(in.StartTime),
|
|
"end_time": timePtrToJSON(in.EndTime),
|
|
"pinned": in.Pinned,
|
|
"archived": in.Archived,
|
|
}
|
|
}
|
|
|
|
// publicBundle mirrors the reader's metadata.projax.public.* shape.
|
|
func publicBundle(enabled bool, description, liveURL, sourceURL string, screenshots []string) map[string]any {
|
|
return map[string]any{
|
|
"enabled": enabled,
|
|
"description": description,
|
|
"live_url": liveURL,
|
|
"source_url": sourceURL,
|
|
"screenshots": orEmpty(screenshots),
|
|
}
|
|
}
|
|
|
|
// edgeMetadataForLink produces the edge metadata the reader's linkFromEdge
|
|
// can decode back into an ItemLink: the typed per-ref_type payload, the
|
|
// free-form rel under projax_rel, the canonical ref_id, an optional
|
|
// event_date, the note (GAP G2), and any caller-supplied extras.
|
|
func edgeMetadataForLink(refType, refID, rel string, note *string, eventDate *time.Time, extra map[string]any) map[string]any {
|
|
m := map[string]any{}
|
|
maps.Copy(m, extra)
|
|
m["projax_rel"] = rel
|
|
m["ref_id"] = refID
|
|
switch refType {
|
|
case "caldav-list":
|
|
m["url"] = refID
|
|
case "gitea-repo":
|
|
if owner, repo, ok := splitOwnerRepo(refID); ok {
|
|
m["owner"], m["repo"] = owner, repo
|
|
}
|
|
case "gitea-issue":
|
|
if owner, repo, num, ok := splitOwnerRepoIssue(refID); ok {
|
|
m["owner"], m["repo"], m["number"] = owner, repo, num
|
|
}
|
|
case "mai-project":
|
|
m["mai_project_id"] = refID
|
|
case "url", "doc", "document", "note":
|
|
m["url"] = refID
|
|
}
|
|
if eventDate != nil {
|
|
m["event_date"] = eventDate.Format("2006-01-02")
|
|
}
|
|
if note != nil && *note != "" {
|
|
m["note"] = *note
|
|
}
|
|
return m
|
|
}
|
|
|
|
func splitOwnerRepo(s string) (string, string, bool) {
|
|
parts := strings.SplitN(s, "/", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", false
|
|
}
|
|
return parts[0], parts[1], true
|
|
}
|
|
|
|
func splitOwnerRepoIssue(s string) (string, string, int, bool) {
|
|
hash := strings.LastIndex(s, "#")
|
|
if hash < 0 {
|
|
return "", "", 0, false
|
|
}
|
|
owner, repo, ok := splitOwnerRepo(s[:hash])
|
|
if !ok {
|
|
return "", "", 0, false
|
|
}
|
|
var num int
|
|
if _, err := fmt.Sscanf(s[hash+1:], "%d", &num); err != nil || num <= 0 {
|
|
return "", "", 0, false
|
|
}
|
|
return owner, repo, num, true
|
|
}
|
|
|
|
// ====================================================================
|
|
// Small utilities
|
|
// ====================================================================
|
|
|
|
// newUUIDv4 mints a random RFC-4122 v4 uuid without pulling a uuid
|
|
// dependency — projax only needs the projax_origin audit marker to be a
|
|
// non-empty, unique-enough string.
|
|
func newUUIDv4() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
|
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
|
}
|
|
|
|
func dedupe(in []string) []string {
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
if seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func orEmpty(in []string) []string {
|
|
if in == nil {
|
|
return []string{}
|
|
}
|
|
return in
|
|
}
|
|
|
|
func timePtrToJSON(t *time.Time) any {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|