Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id 97D6A1EB
	for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 18 Aug 2026 22:32:54 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.128.51; helo=mail-wm1-f51.google.com; envelope-from=peter@netlandish.com; receiver=<UNKNOWN> 
Authentication-Results: mail.netlandish.com;
	dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=TEyMBo1z
Received: from mail-wm1-f51.google.com (mail-wm1-f51.google.com [209.85.128.51])
	by mail.netlandish.com (Postfix) with ESMTP id 33BFC1D81AF
	for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 18 Aug 2026 22:33:31 +0000 (UTC)
Received: by mail-wm1-f51.google.com with SMTP id 5b1f17b1804b1-4957eefd361so2664375e9.1
        for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 18 Aug 2026 15:33:31 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1787092411; x=1787697211; darn=lists.code.netlandish.com;
        h=content-transfer-encoding:content-type:mime-version:message-id:date
         :subject:cc:to:from:from:to:cc:subject:date:message-id:reply-to
         :content-type;
        bh=lhLrWRBPwUAXiOYpbXvELhX88mKdkHjoT3nJOBFVoNw=;
        b=TEyMBo1z75eTVhvmAbKe7ZRN1bfXys0vtMFGttD4seypgnslKFduyAxPBtGXVHDA7l
         UjaojJZSC2M9rNwq1Ut02WOZXD8EbYFm/JR6drtr36NV2ehTB7lllecGhRjFWId9seym
         OKBQVczbfhpLKoRM851PZVHd12YZyYQpjjZNU=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20251104; t=1787092411; x=1787697211;
        h=content-transfer-encoding:content-type:mime-version:message-id:date
         :subject:cc:to:from:x-gm-gg:x-gm-message-state:from:to:cc:subject
         :date:message-id:reply-to:content-type;
        bh=lhLrWRBPwUAXiOYpbXvELhX88mKdkHjoT3nJOBFVoNw=;
        b=ZYnS1RE2IcCs/jp1DeToTr1vT7guRiHUyMPbPmxS0/8E6WcBXkWvR77SvrN1TcXbVi
         B8yCThVPdpbpK0dYE+p/UVf78gAoTyNpgY96AhxgVF+SD2iU8IpInydlS1QWMfG+Cicv
         7/MLW9sBScCJAC/T7DqT85s4+uw4jQSKJbaNiN4MUNVtYknMCFVHU8ss6O8HxGErPGgU
         jojyz4ka+a2Wv6lWE5/LBBaqRAyjdA2cMuU8hgNVMUmTljAH4IPsEWxK1x4dnh+buA9e
         UwMEVJ5qVhQNfBkuv+a8m/sxrqFQydkTJR9+RYnW5a87y6q3anCzpVh24ACUtmYzpcAK
         RfCw==
X-Gm-Message-State: AOJu0YygwJfimskkJ1bcX43ctYjTFRoEv3R+zZOQ5PMV78ijw1Lzd31X
	wo2yXS4k4QBDCzsxWd32GWoL6b5SK9uFQ0XC97IZXSyYRxI/JsKMzsjjxCcD6ikH0PTdOfhTnw8
	1EzwTV3TuWg==
X-Gm-Gg: AR+sD13FjinQiy5VvMLfIaBJ8XZzeHrK09ucicchoywU4LVWpT0P7weewpDJj/1ERJE
	PgtPb4SPa3AKGc04JNfXn7gcBiCOk5xMUaCOL6zEUQRg5V1/9g0EnOBwGIHFMqtWDIz0cJgH24A
	xXX4TeBZRi/MSRIIl+w5HxAJWFvrp0A9rXy5oJwqL//Ynq405HdkELo8PJF+WYKlflFZLLQRUvO
	rmAlzKgQT7r75C85AHZEC5oVsRvJVs9YdQUTNnIG0Jw4e4WvcFPh3p9EOrg2CDi/Ig7YLRj3zTT
	n9QhOCtxet+eNh6L1BDt8ZZ9oqCs90g/yXi/QIaZd7u6UNtvll+lvblsjsdeJ/8eSq8OX6CdDrY
	ar0kL2paOpct47dRWmN/EzJL7pF27SgacVTXbiQMvjOMFwYaUs50vjZ9hdYlmV+TzLdpayQiHi5
	jt6CK0C9HIS6rJmPdmbIjcoU9LYpjH1ABiA/55Y3r1PExNLFgZs/f8afCZzyzRuJ1isRDSteOX4
	XUn0pewe5IkMuIf1tLbLe5uh8MPHu3RYfKWDlQhcYAaglVkoGEvjETh
X-Received: by 2002:a05:600c:1385:b0:495:5890:8f6c with SMTP id 5b1f17b1804b1-499aa17dcd0mr5113485e9.7.1787092410693;
        Tue, 18 Aug 2026 15:33:30 -0700 (PDT)
Received: from localhost (dynamic-077-013-149-063.77.13.pool.telefonica.de. [77.13.149.63])
        by smtp.gmail.com with ESMTPSA id 5b1f17b1804b1-499a9e805a4sm6806835e9.4.2026.08.18.15.33.28
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Tue, 18 Aug 2026 15:33:30 -0700 (PDT)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Cc: Peter Sanchez <peter@netlandish.com>
Subject: [PATCH links] gobwebs-graphql bump
Date: Tue, 18 Aug 2026 16:33:20 -0600
Message-ID: <20260818223326.20527-1-peter@netlandish.com>
X-Mailer: git-send-email 2.54.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Part of ongoing gobwebs ecosystem updates. This moved valid and cursor
modules to the gobwebs-graphql module.

Changelog-updated: moved graphql `valid` and `cursor` modules/objects to
 the latest version of gobwebs-graphql
---
 api/gqlgen.yml                |   2 +-
 api/graph/generated.go        |  65 +++++-----
 api/graph/model/cursor.go     |  59 ---------
 api/graph/model/models_gen.go | 115 ++++++++---------
 api/graph/pagination.go       |  17 +--
 api/graph/pagination_test.go  |  27 ++--
 api/graph/schema.resolvers.go |   6 +-
 go.mod                        |   2 +-
 go.sum                        |   4 +-
 helpers.go                    |  10 +-
 helpers_test.go               |   4 +-
 valid/valid.go                | 231 ----------------------------------
 12 files changed, 128 insertions(+), 414 deletions(-)
 delete mode 100644 api/graph/model/cursor.go
 delete mode 100644 valid/valid.go

diff --git a/api/gqlgen.yml b/api/gqlgen.yml
index 6f9e4a4..4b2dd8d 100644
--- a/api/gqlgen.yml
+++ b/api/gqlgen.yml
@@ -52,7 +52,7 @@ autobind:
 models:
   Cursor:
     model:
-      - links/api/graph/model.Cursor
+      - netlandish.com/x/gobwebs-graphql/cursor.Cursor
   ID:
     model:
       - github.com/99designs/gqlgen/graphql.ID
diff --git a/api/graph/generated.go b/api/graph/generated.go
index af1105f..9a3507d 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -20,6 +20,7 @@ import (
 	gqlparser "github.com/vektah/gqlparser/v2"
 	"github.com/vektah/gqlparser/v2/ast"
 	"netlandish.com/x/gobwebs-auditlog"
+	"netlandish.com/x/gobwebs-graphql/cursor"
 )
 
 // region    ************************** generated!.gotpl **************************
@@ -16568,9 +16569,9 @@ func (ec *executionContext) _PageInfo_cursor(ctx context.Context, field graphql.
 		}
 		return graphql.Null
 	}
-	res := resTmp.(model.Cursor)
+	res := resTmp.(cursor.Cursor)
 	fc.Result = res
-	return ec.marshalNCursor2linksᚋapiᚋgraphᚋmodelᚐCursor(ctx, field.Selections, res)
+	return ec.marshalNCursor2netlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_PageInfo_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -25518,14 +25519,14 @@ func (ec *executionContext) unmarshalInputAuditLogInput(ctx context.Context, obj
 			it.ListingID = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -25696,14 +25697,14 @@ func (ec *executionContext) unmarshalInputGetAdminDomainInput(ctx context.Contex
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -25772,14 +25773,14 @@ func (ec *executionContext) unmarshalInputGetAdminOrganizationsInput(ctx context
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -25827,14 +25828,14 @@ func (ec *executionContext) unmarshalInputGetFeedInput(ctx context.Context, obj
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -25910,14 +25911,14 @@ func (ec *executionContext) unmarshalInputGetLinkInput(ctx context.Context, obj
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -26014,14 +26015,14 @@ func (ec *executionContext) unmarshalInputGetLinkShortInput(ctx context.Context,
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -26090,14 +26091,14 @@ func (ec *executionContext) unmarshalInputGetListingDetailInput(ctx context.Cont
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -26138,14 +26139,14 @@ func (ec *executionContext) unmarshalInputGetListingInput(ctx context.Context, o
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -26200,14 +26201,14 @@ func (ec *executionContext) unmarshalInputGetOrganizationsInput(ctx context.Cont
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -26255,14 +26256,14 @@ func (ec *executionContext) unmarshalInputGetPaymentInput(ctx context.Context, o
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -26331,14 +26332,14 @@ func (ec *executionContext) unmarshalInputGetTagsInput(ctx context.Context, obj
 			it.Service = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -26379,14 +26380,14 @@ func (ec *executionContext) unmarshalInputGetUserInput(ctx context.Context, obj
 			it.Limit = data
 		case "after":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
 			it.After = data
 		case "before":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
-			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			data, err := ec.unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx, v)
 			if err != nil {
 				return it, err
 			}
@@ -32193,13 +32194,13 @@ func (ec *executionContext) marshalNCloudOrderType2linksᚋapiᚋgraphᚋmodel
 	return v
 }
 
-func (ec *executionContext) unmarshalNCursor2linksᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, v any) (model.Cursor, error) {
-	var res model.Cursor
+func (ec *executionContext) unmarshalNCursor2netlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx context.Context, v any) (cursor.Cursor, error) {
+	var res cursor.Cursor
 	err := res.UnmarshalGQL(v)
 	return res, graphql.ErrorOnPath(ctx, err)
 }
 
-func (ec *executionContext) marshalNCursor2linksᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, sel ast.SelectionSet, v model.Cursor) graphql.Marshaler {
+func (ec *executionContext) marshalNCursor2netlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx context.Context, sel ast.SelectionSet, v cursor.Cursor) graphql.Marshaler {
 	return v
 }
 
@@ -33601,16 +33602,16 @@ func (ec *executionContext) unmarshalOCompleteRegisterInput2ᚖlinksᚋapiᚋgra
 	return &res, graphql.ErrorOnPath(ctx, err)
 }
 
-func (ec *executionContext) unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, v any) (*model.Cursor, error) {
+func (ec *executionContext) unmarshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx context.Context, v any) (*cursor.Cursor, error) {
 	if v == nil {
 		return nil, nil
 	}
-	var res = new(model.Cursor)
+	var res = new(cursor.Cursor)
 	err := res.UnmarshalGQL(v)
 	return res, graphql.ErrorOnPath(ctx, err)
 }
 
-func (ec *executionContext) marshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx context.Context, sel ast.SelectionSet, v *model.Cursor) graphql.Marshaler {
+func (ec *executionContext) marshalOCursor2ᚖnetlandishᚗcomᚋxᚋgobwebsᚑgraphqlᚋcursorᚐCursor(ctx context.Context, sel ast.SelectionSet, v *cursor.Cursor) graphql.Marshaler {
 	if v == nil {
 		return graphql.Null
 	}
diff --git a/api/graph/model/cursor.go b/api/graph/model/cursor.go
deleted file mode 100644
index f4e05eb..0000000
--- a/api/graph/model/cursor.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package model
-
-import (
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"io"
-	"links/valid"
-
-	"github.com/vektah/gqlparser/v2/gqlerror"
-)
-
-const (
-	PaginationDefault int = 25
-	PaginationMax     int = 100
-)
-
-// Cursor is used to represent a graph cursor in the resolver
-// as well in the handler parsin data
-type Cursor struct {
-	After  int
-	Before int
-	Limit  int
-}
-
-// errInvalidCursor carries a validation code so consumers running the error
-// through ParseInputErrors render a form error rather than falling through to
-// the raw error path, which the web tier reports as a 500.
-func errInvalidCursor() error {
-	return &gqlerror.Error{
-		Message:    "Invalid cursor",
-		Extensions: map[string]any{"code": valid.ErrValidationGlobalCode},
-	}
-}
-
-func (c *Cursor) UnmarshalGQL(v any) error {
-	enc, ok := v.(string)
-	if !ok {
-		return fmt.Errorf("cursor must be strings")
-	}
-	plain, err := base64.StdEncoding.DecodeString(enc)
-	if plain == nil || err != nil {
-		return errInvalidCursor()
-	}
-	if err = json.Unmarshal(plain, c); err != nil {
-		return errInvalidCursor()
-	}
-	return nil
-}
-
-func (c Cursor) MarshalGQL(w io.Writer) {
-	data, err := json.Marshal(c)
-	if err != nil {
-		panic(err)
-	}
-	w.Write([]byte("\""))
-	w.Write([]byte(base64.StdEncoding.EncodeToString(data)))
-	w.Write([]byte("\""))
-}
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index 79ddf39..fb3b6f5 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/99designs/gqlgen/graphql"
 	"netlandish.com/x/gobwebs-auditlog"
+	"netlandish.com/x/gobwebs-graphql/cursor"
 )
 
 type AddListingInput struct {
@@ -114,12 +115,12 @@ type AuditLogCursor struct {
 }
 
 type AuditLogInput struct {
-	UserID    *int    `json:"userId,omitempty"`
-	OrgSlug   *string `json:"orgSlug,omitempty"`
-	ListingID *int    `json:"listingId,omitempty"`
-	After     *Cursor `json:"after,omitempty"`
-	Before    *Cursor `json:"before,omitempty"`
-	Limit     *int    `json:"limit,omitempty"`
+	UserID    *int           `json:"userId,omitempty"`
+	OrgSlug   *string        `json:"orgSlug,omitempty"`
+	ListingID *int           `json:"listingId,omitempty"`
+	After     *cursor.Cursor `json:"after,omitempty"`
+	Before    *cursor.Cursor `json:"before,omitempty"`
+	Limit     *int           `json:"limit,omitempty"`
 }
 
 type BookmarkCursor struct {
@@ -169,8 +170,8 @@ type EmailPostUser struct {
 
 type GetAdminDomainInput struct {
 	Limit         *int           `json:"limit,omitempty"`
-	After         *Cursor        `json:"after,omitempty"`
-	Before        *Cursor        `json:"before,omitempty"`
+	After         *cursor.Cursor `json:"after,omitempty"`
+	Before        *cursor.Cursor `json:"before,omitempty"`
 	Search        *string        `json:"search,omitempty"`
 	FilterLevel   *DomainLevel   `json:"filterLevel,omitempty"`
 	FilterService *DomainService `json:"filterService,omitempty"`
@@ -179,17 +180,17 @@ type GetAdminDomainInput struct {
 }
 
 type GetAdminOrganizationsInput struct {
-	Limit   *int    `json:"limit,omitempty"`
-	After   *Cursor `json:"after,omitempty"`
-	Before  *Cursor `json:"before,omitempty"`
-	Search  *string `json:"search,omitempty"`
-	OwnerID *int    `json:"ownerId,omitempty"`
+	Limit   *int           `json:"limit,omitempty"`
+	After   *cursor.Cursor `json:"after,omitempty"`
+	Before  *cursor.Cursor `json:"before,omitempty"`
+	Search  *string        `json:"search,omitempty"`
+	OwnerID *int           `json:"ownerId,omitempty"`
 }
 
 type GetFeedInput struct {
 	Limit         *int            `json:"limit,omitempty"`
-	After         *Cursor         `json:"after,omitempty"`
-	Before        *Cursor         `json:"before,omitempty"`
+	After         *cursor.Cursor  `json:"after,omitempty"`
+	Before        *cursor.Cursor  `json:"before,omitempty"`
 	Tag           *string         `json:"tag,omitempty"`
 	ExcludeTag    *string         `json:"excludeTag,omitempty"`
 	Search        *string         `json:"search,omitempty"`
@@ -200,8 +201,8 @@ type GetFeedInput struct {
 type GetLinkInput struct {
 	OrgSlug       *string         `json:"orgSlug,omitempty"`
 	Limit         *int            `json:"limit,omitempty"`
-	After         *Cursor         `json:"after,omitempty"`
-	Before        *Cursor         `json:"before,omitempty"`
+	After         *cursor.Cursor  `json:"after,omitempty"`
+	Before        *cursor.Cursor  `json:"before,omitempty"`
 	Tag           *string         `json:"tag,omitempty"`
 	ExcludeTag    *string         `json:"excludeTag,omitempty"`
 	Search        *string         `json:"search,omitempty"`
@@ -213,64 +214,64 @@ type GetLinkInput struct {
 }
 
 type GetLinkShortInput struct {
-	OrgSlug    string  `json:"orgSlug"`
-	Limit      *int    `json:"limit,omitempty"`
-	After      *Cursor `json:"after,omitempty"`
-	Before     *Cursor `json:"before,omitempty"`
-	Tag        *string `json:"tag,omitempty"`
-	ExcludeTag *string `json:"excludeTag,omitempty"`
-	DomainID   *int    `json:"domainId,omitempty"`
+	OrgSlug    string         `json:"orgSlug"`
+	Limit      *int           `json:"limit,omitempty"`
+	After      *cursor.Cursor `json:"after,omitempty"`
+	Before     *cursor.Cursor `json:"before,omitempty"`
+	Tag        *string        `json:"tag,omitempty"`
+	ExcludeTag *string        `json:"excludeTag,omitempty"`
+	DomainID   *int           `json:"domainId,omitempty"`
 }
 
 type GetListingDetailInput struct {
-	DomainID int     `json:"domainId"`
-	Slug     *string `json:"slug,omitempty"`
-	Limit    *int    `json:"limit,omitempty"`
-	After    *Cursor `json:"after,omitempty"`
-	Before   *Cursor `json:"before,omitempty"`
+	DomainID int            `json:"domainId"`
+	Slug     *string        `json:"slug,omitempty"`
+	Limit    *int           `json:"limit,omitempty"`
+	After    *cursor.Cursor `json:"after,omitempty"`
+	Before   *cursor.Cursor `json:"before,omitempty"`
 }
 
 type GetListingInput struct {
-	OrgSlug    string  `json:"orgSlug"`
-	Limit      *int    `json:"limit,omitempty"`
-	After      *Cursor `json:"after,omitempty"`
-	Before     *Cursor `json:"before,omitempty"`
-	Tag        *string `json:"tag,omitempty"`
-	ExcludeTag *string `json:"excludeTag,omitempty"`
-	DomainID   *int    `json:"domainId,omitempty"`
+	OrgSlug    string         `json:"orgSlug"`
+	Limit      *int           `json:"limit,omitempty"`
+	After      *cursor.Cursor `json:"after,omitempty"`
+	Before     *cursor.Cursor `json:"before,omitempty"`
+	Tag        *string        `json:"tag,omitempty"`
+	ExcludeTag *string        `json:"excludeTag,omitempty"`
+	DomainID   *int           `json:"domainId,omitempty"`
 }
 
 type GetOrganizationsInput struct {
-	Limit  *int    `json:"limit,omitempty"`
-	After  *Cursor `json:"after,omitempty"`
-	Before *Cursor `json:"before,omitempty"`
-	Search *string `json:"search,omitempty"`
+	Limit  *int           `json:"limit,omitempty"`
+	After  *cursor.Cursor `json:"after,omitempty"`
+	Before *cursor.Cursor `json:"before,omitempty"`
+	Search *string        `json:"search,omitempty"`
 }
 
 type GetPaymentInput struct {
-	OrgSlug   *string `json:"orgSlug,omitempty"`
-	Limit     *int    `json:"limit,omitempty"`
-	After     *Cursor `json:"after,omitempty"`
-	Before    *Cursor `json:"before,omitempty"`
-	Interval  *int    `json:"interval,omitempty"`
-	DateStart *string `json:"dateStart,omitempty"`
-	DateEnd   *string `json:"dateEnd,omitempty"`
-	Filter    *bool   `json:"filter,omitempty"`
+	OrgSlug   *string        `json:"orgSlug,omitempty"`
+	Limit     *int           `json:"limit,omitempty"`
+	After     *cursor.Cursor `json:"after,omitempty"`
+	Before    *cursor.Cursor `json:"before,omitempty"`
+	Interval  *int           `json:"interval,omitempty"`
+	DateStart *string        `json:"dateStart,omitempty"`
+	DateEnd   *string        `json:"dateEnd,omitempty"`
+	Filter    *bool          `json:"filter,omitempty"`
 }
 
 type GetTagsInput struct {
 	OrgSlug string         `json:"orgSlug"`
 	Service *DomainService `json:"service,omitempty"`
-	After   *Cursor        `json:"after,omitempty"`
-	Before  *Cursor        `json:"before,omitempty"`
+	After   *cursor.Cursor `json:"after,omitempty"`
+	Before  *cursor.Cursor `json:"before,omitempty"`
 	Limit   *int           `json:"limit,omitempty"`
 }
 
 type GetUserInput struct {
-	Limit  *int    `json:"limit,omitempty"`
-	After  *Cursor `json:"after,omitempty"`
-	Before *Cursor `json:"before,omitempty"`
-	Search *string `json:"search,omitempty"`
+	Limit  *int           `json:"limit,omitempty"`
+	After  *cursor.Cursor `json:"after,omitempty"`
+	Before *cursor.Cursor `json:"before,omitempty"`
+	Search *string        `json:"search,omitempty"`
 }
 
 type LinkInput struct {
@@ -371,9 +372,9 @@ type OrganizationStats struct {
 
 // This is used in various cursor objects for pagination
 type PageInfo struct {
-	Cursor      Cursor `json:"cursor"`
-	HasNextPage bool   `json:"hasNextPage"`
-	HasPrevPage bool   `json:"hasPrevPage"`
+	Cursor      cursor.Cursor `json:"cursor"`
+	HasNextPage bool          `json:"hasNextPage"`
+	HasPrevPage bool          `json:"hasPrevPage"`
 }
 
 type Payment struct {
diff --git a/api/graph/pagination.go b/api/graph/pagination.go
index 09068e1..9727afa 100644
--- a/api/graph/pagination.go
+++ b/api/graph/pagination.go
@@ -5,6 +5,7 @@ import (
 	"links/api/graph/model"
 
 	sq "github.com/Masterminds/squirrel"
+	"netlandish.com/x/gobwebs-graphql/cursor"
 	"netlandish.com/x/gobwebs/database"
 )
 
@@ -14,7 +15,7 @@ type contextKey struct {
 	name string
 }
 
-// PaginationContext is used to override `model.PaginationMax` value
+// PaginationContext is used to override `cursor.PaginationMax` value
 func PaginationContext(ctx context.Context, limit int) context.Context {
 	return context.WithValue(ctx, paginationCtxKey, limit)
 }
@@ -23,12 +24,12 @@ func PaginationContext(ctx context.Context, limit int) context.Context {
 func ForPaginationContext(ctx context.Context) int {
 	limit, ok := ctx.Value(paginationCtxKey).(int)
 	if !ok {
-		return model.PaginationMax
+		return cursor.PaginationMax
 	}
 	return limit
 }
 
-func PaginateResults[T any](items []T, limit int, before, after *model.Cursor,
+func PaginateResults[T any](items []T, limit int, before, after *cursor.Cursor,
 	getID func(T) int) ([]T, *model.PageInfo) {
 	overFetched := len(items) > limit
 	if overFetched {
@@ -57,7 +58,7 @@ func PaginateResults[T any](items []T, limit int, before, after *model.Cursor,
 	return items, &pageInfo
 }
 
-func cursorField(before, after *model.Cursor, idField, orderDir string) sq.Sqlizer {
+func cursorField(before, after *cursor.Cursor, idField, orderDir string) sq.Sqlizer {
 	if after != nil {
 		if orderDir == "DESC" {
 			return sq.Lt{idField: after.After}
@@ -80,11 +81,11 @@ func QueryModel[T any](
 	opts *database.FilterOptions,
 	idField, orderDir string,
 	limit *int,
-	before, after *model.Cursor,
+	before, after *cursor.Cursor,
 	getModels func(context.Context, *database.FilterOptions) ([]T, error),
 	getID func(T) int,
 ) ([]T, *model.PageInfo, error) {
-	numElements := model.PaginationDefault
+	numElements := cursor.PaginationDefault
 	if after != nil {
 		opts.Filter = sq.And{
 			opts.Filter,
@@ -114,10 +115,10 @@ func QueryModel[T any](
 	// would drop the SQL LIMIT clause entirely in GetBuilder and then panic the
 	// slice reslice in PaginateResults.
 	if numElements <= 0 {
-		numElements = model.PaginationDefault
+		numElements = cursor.PaginationDefault
 	}
 
-	// maxLimit will default to model.PaginationMax
+	// maxLimit will default to cursor.PaginationMax
 	maxLimit := ForPaginationContext(ctx)
 	if numElements > maxLimit {
 		numElements = maxLimit
diff --git a/api/graph/pagination_test.go b/api/graph/pagination_test.go
index 6307936..86bae1c 100644
--- a/api/graph/pagination_test.go
+++ b/api/graph/pagination_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"netlandish.com/x/gobwebs-graphql/cursor"
 	"netlandish.com/x/gobwebs/database"
 )
 
@@ -16,7 +17,7 @@ func TestPaginationForwardAndBackward(t *testing.T) {
 		allItems = append(allItems, i)
 	}
 
-	getPage := func(after, before *model.Cursor) ([]int, *model.PageInfo) {
+	getPage := func(after, before *cursor.Cursor) ([]int, *model.PageInfo) {
 		var filtered []int
 		for _, id := range allItems {
 			// ORDER BY id DESC
@@ -38,7 +39,7 @@ func TestPaginationForwardAndBackward(t *testing.T) {
 	}
 
 	t.Run("Forward Pagination", func(t *testing.T) {
-		var after *model.Cursor
+		var after *cursor.Cursor
 		seen := make(map[int]bool)
 		for {
 			page, info := getPage(after, nil)
@@ -52,13 +53,13 @@ func TestPaginationForwardAndBackward(t *testing.T) {
 			if !info.HasNextPage {
 				break
 			}
-			after = &model.Cursor{After: info.Cursor.After, Limit: 5}
+			after = &cursor.Cursor{After: info.Cursor.After, Limit: 5}
 		}
 		assert.Equal(t, 26, len(seen))
 	})
 
 	t.Run("Backward Pagination", func(t *testing.T) {
-		before := &model.Cursor{Before: 0}
+		before := &cursor.Cursor{Before: 0}
 		seen := make(map[int]bool)
 		for {
 			page, info := getPage(nil, before)
@@ -72,7 +73,7 @@ func TestPaginationForwardAndBackward(t *testing.T) {
 			if !info.HasPrevPage {
 				break
 			}
-			before = &model.Cursor{Before: info.Cursor.Before, Limit: 5}
+			before = &cursor.Cursor{Before: info.Cursor.Before, Limit: 5}
 		}
 		assert.Equal(t, 26, len(seen))
 	})
@@ -84,7 +85,7 @@ func TestQueryModelClampsCursorLimit(t *testing.T) {
 		items = append(items, i)
 	}
 
-	run := func(t *testing.T, limit *int, before, after *model.Cursor) int {
+	run := func(t *testing.T, limit *int, before, after *cursor.Cursor) int {
 		t.Helper()
 		var gotLimit int
 		getModels := func(_ context.Context, opts *database.FilterOptions) ([]int, error) {
@@ -103,22 +104,22 @@ func TestQueryModelClampsCursorLimit(t *testing.T) {
 	// A negative cursor limit used to leave opts.Limit at zero, which drops the SQL
 	// LIMIT clause entirely and then panics on the reslice in PaginateResults.
 	t.Run("negative after limit", func(t *testing.T) {
-		assert.Equal(t, model.PaginationDefault+1,
-			run(t, nil, nil, &model.Cursor{After: 150, Limit: -1}))
+		assert.Equal(t, cursor.PaginationDefault+1,
+			run(t, nil, nil, &cursor.Cursor{After: 150, Limit: -1}))
 	})
 
 	t.Run("zero before limit", func(t *testing.T) {
-		assert.Equal(t, model.PaginationDefault+1,
-			run(t, nil, &model.Cursor{Before: 150, Limit: 0}, nil))
+		assert.Equal(t, cursor.PaginationDefault+1,
+			run(t, nil, &cursor.Cursor{Before: 150, Limit: 0}, nil))
 	})
 
 	t.Run("negative explicit limit falls back to cursor", func(t *testing.T) {
 		negative := -5
-		assert.Equal(t, 11, run(t, &negative, nil, &model.Cursor{After: 150, Limit: 10}))
+		assert.Equal(t, 11, run(t, &negative, nil, &cursor.Cursor{After: 150, Limit: 10}))
 	})
 
 	t.Run("oversized cursor limit still capped", func(t *testing.T) {
-		assert.Equal(t, model.PaginationMax+1,
-			run(t, nil, nil, &model.Cursor{After: 150, Limit: 10000}))
+		assert.Equal(t, cursor.PaginationMax+1,
+			run(t, nil, nil, &cursor.Cursor{After: 150, Limit: 10000}))
 	})
 }
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index bdb6ccc..1033be8 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -23,7 +23,6 @@ import (
 	"links/domain"
 	"links/internal/localizer"
 	"links/models"
-	"links/valid"
 	"net/url"
 	"os"
 	"regexp"
@@ -40,8 +39,9 @@ import (
 	"golang.org/x/image/draw"
 	"golang.org/x/net/idna"
 	"netlandish.com/x/gobwebs"
-	auditlog "netlandish.com/x/gobwebs-auditlog"
-	oauth2 "netlandish.com/x/gobwebs-oauth2"
+	"netlandish.com/x/gobwebs-auditlog"
+	"netlandish.com/x/gobwebs-graphql/valid"
+	"netlandish.com/x/gobwebs-oauth2"
 	gaccounts "netlandish.com/x/gobwebs/accounts"
 	"netlandish.com/x/gobwebs/crypto"
 	"netlandish.com/x/gobwebs/database"
diff --git a/go.mod b/go.mod
index 4c3c217..e6b935e 100644
--- a/go.mod
+++ b/go.mod
@@ -36,7 +36,7 @@ require (
 	netlandish.com/x/gobwebs v0.1.15
 	netlandish.com/x/gobwebs-auditlog v0.3.2
 	netlandish.com/x/gobwebs-formguard v0.2.13
-	netlandish.com/x/gobwebs-graphql v0.2.14
+	netlandish.com/x/gobwebs-graphql v0.3.0
 	netlandish.com/x/gobwebs-oauth2 v0.2.13
 	netlandish.com/x/gobwebs-ses-feedback v0.2.13
 	petersanchez.com/x/carrier v0.2.4
diff --git a/go.sum b/go.sum
index d2cccaf..4469686 100644
--- a/go.sum
+++ b/go.sum
@@ -2588,8 +2588,8 @@ netlandish.com/x/gobwebs-auditlog v0.3.2 h1:mG3y/gA9wLFbm172JAg3qhQXsviL7AEUw1TA
 netlandish.com/x/gobwebs-auditlog v0.3.2/go.mod h1:Hp/tmzsy6Cum7w/s3xeA7iXHOkTVCxLYmU7mEXJZuL4=
 netlandish.com/x/gobwebs-formguard v0.2.13 h1:bqolzDBFLYk9V3qglIXccMEefIoFlCHrte7UjRyN9EY=
 netlandish.com/x/gobwebs-formguard v0.2.13/go.mod h1:3bH1yWdbFauqMr+CP5hmsOSt4VHIY4qfo4wPQfRnasg=
-netlandish.com/x/gobwebs-graphql v0.2.14 h1:rQ98bzgEat9/FSbKTqYwJi6juIQ2Rh1RTc50dG2XAl8=
-netlandish.com/x/gobwebs-graphql v0.2.14/go.mod h1:T45dDN3aIbpOX+rjAMyxa/yZuB3HZqgPAyEEypVWIiE=
+netlandish.com/x/gobwebs-graphql v0.3.0 h1:E8v+R4TYBezP1zgbXgK7aLFA+jQAi1VIlz6l+DucOis=
+netlandish.com/x/gobwebs-graphql v0.3.0/go.mod h1:ihf/UpV46TJSVgGw0avUeMw/YANC08BzOZJ+fvk+zyM=
 netlandish.com/x/gobwebs-oauth2 v0.2.13 h1:Wco3mWNzXIq5PxdgrvWLXKO6kCH6G0GiHJgkyc9U7KI=
 netlandish.com/x/gobwebs-oauth2 v0.2.13/go.mod h1:4+LM3EHqQ0gtONbVdExgZBNckI/Pmx23XELZiXv7fpw=
 netlandish.com/x/gobwebs-ses-feedback v0.2.13 h1:PFKHqv4cOfOG+rx5zQnl/tmYYQRrW+SP1SaKJ29b1e8=
diff --git a/helpers.go b/helpers.go
index ceaf027..6617434 100644
--- a/helpers.go
+++ b/helpers.go
@@ -9,11 +9,9 @@ import (
 	"fmt"
 	"html/template"
 	"io"
-	"links/api/graph/model"
 	"links/domain"
 	"links/internal/localizer"
 	"links/models"
-	"links/valid"
 	"mime/multipart"
 	"net"
 	"net/http"
@@ -40,6 +38,8 @@ import (
 	"golang.org/x/time/rate"
 	"netlandish.com/x/gobwebs"
 	auditlog "netlandish.com/x/gobwebs-auditlog"
+	"netlandish.com/x/gobwebs-graphql/cursor"
+	"netlandish.com/x/gobwebs-graphql/valid"
 	"netlandish.com/x/gobwebs/config"
 	"netlandish.com/x/gobwebs/core"
 	"netlandish.com/x/gobwebs/crypto"
@@ -501,7 +501,7 @@ func ParsePendingBaseURLs(ctx context.Context, userAgent string) error {
 }
 
 func ValidCursor(v string) bool {
-	var cur model.Cursor
+	var cur cursor.Cursor
 	return cur.UnmarshalGQL(v) == nil
 }
 
@@ -530,7 +530,7 @@ func SafeRedirect(next, fallback string) string {
 }
 
 // GetPaginationParams returns the params needed for cursor pagination
-func GetPaginationParams(c echo.Context, pagvar, cursor string, exclude ...string) template.URL {
+func GetPaginationParams(c echo.Context, pagvar, cur string, exclude ...string) template.URL {
 	q := make(url.Values)
 	oldq := c.QueryParams()
 	for k, val := range oldq {
@@ -541,7 +541,7 @@ func GetPaginationParams(c echo.Context, pagvar, cursor string, exclude ...strin
 			q.Add(k, v)
 		}
 	}
-	q.Set(pagvar, cursor)
+	q.Set(pagvar, cur)
 	return template.URL(q.Encode())
 }
 
diff --git a/helpers_test.go b/helpers_test.go
index 2cf5938..710ddcf 100644
--- a/helpers_test.go
+++ b/helpers_test.go
@@ -2,7 +2,6 @@ package links
 
 import (
 	"encoding/base64"
-	"links/api/graph/model"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -11,11 +10,12 @@ import (
 	"git.sr.ht/~emersion/gqlclient"
 	"github.com/labstack/echo/v4"
 	"github.com/stretchr/testify/require"
+	"netlandish.com/x/gobwebs-graphql/cursor"
 )
 
 func validCursor(t *testing.T) string {
 	t.Helper()
-	cur := model.Cursor{After: 199071, Before: 207789, Limit: 25}
+	cur := cursor.Cursor{After: 199071, Before: 207789, Limit: 25}
 	rec := httptest.NewRecorder()
 	cur.MarshalGQL(rec.Body)
 	return rec.Body.String()[1 : rec.Body.Len()-1]
diff --git a/valid/valid.go b/valid/valid.go
deleted file mode 100644
index 30210d4..0000000
--- a/valid/valid.go
+++ /dev/null
@@ -1,231 +0,0 @@
-package valid
-
-import (
-	"context"
-	"errors"
-	"fmt"
-
-	"github.com/99designs/gqlgen/graphql"
-	"github.com/vektah/gqlparser/v2/gqlerror"
-)
-
-// ErrValidationCode ...
-const (
-	ErrValidationCode       int = 100
-	ErrNotFoundCode         int = 200
-	ErrValidationGlobalCode int = 300
-	ErrRestrictedCode       int = 400
-)
-
-var (
-	// ErrAuthorization ...
-	ErrAuthorization = errors.New("Authorizatoin required")
-)
-
-// Validation ...
-type Validation struct {
-	ctx   context.Context
-	input map[string]any
-}
-
-// ValidationError ...
-type ValidationError struct {
-	valid *Validation
-	err   *gqlerror.Error
-}
-
-// Error Returns a new GraphQL error attached to the given field.
-func Error(ctx context.Context, field string, msg string) error {
-	return &gqlerror.Error{
-		Message: msg,
-		Path:    graphql.GetPath(ctx),
-		Extensions: map[string]any{
-			"field": field,
-		},
-	}
-}
-
-// Errorf Returns a new GraphQL error attached to the given field.
-func Errorf(ctx context.Context, field string, msg string, items ...any) error {
-	return &gqlerror.Error{
-		Message: fmt.Sprintf(msg, items...),
-		Path:    graphql.GetPath(ctx),
-		Extensions: map[string]any{
-			"field": field,
-		},
-	}
-}
-
-// New Creates a new validation context.
-func New(ctx context.Context) *Validation {
-	return &Validation{
-		ctx: ctx,
-	}
-}
-
-// WithInput Adds an input map to a validation context.
-func (valid *Validation) WithInput(input map[string]any) *Validation {
-	valid.input = input
-	return valid
-}
-
-// Ok Returns true if no errors were found.
-func (valid *Validation) Ok() bool {
-	return len(graphql.GetErrors(valid.ctx)) == 0
-}
-
-// Optional Fetches an item from the validation context, which must have an input
-// registered. If the field is not present, the callback is not run. Otherwise,
-// the function is called with the value for the user to conduct further
-// validation with.
-func (valid *Validation) Optional(name string, fn func(i any)) {
-	if valid.input == nil {
-		panic("Attempted to validate fields without input")
-	}
-	if o, ok := valid.input[name]; ok {
-		if o == nil {
-			return
-		}
-		fn(o)
-	}
-}
-
-// OptionalString Fetches a string from the validation context, which must have an input
-// registered. If the field is not present, the callback is not run. If
-// present, but not a string, an error is recorded. Otherwise, the function is
-// called with the string for the user to conduct further validation with.
-func (valid *Validation) OptionalString(name string, fn func(s string)) {
-	if valid.input == nil {
-		panic("Attempted to validate fields without input")
-	}
-	if o, ok := valid.input[name]; ok {
-		if o == nil {
-			return
-		}
-		var val string
-		switch s := o.(type) {
-		case string:
-			val = s
-		case *string:
-			val = *s
-		default:
-			valid.
-				Error("Expected %s to be a string", name).
-				WithField(name)
-			return
-		}
-		fn(val)
-	}
-}
-
-// NullableString Fetches a nullable string from the validation context, which must have an
-// input registered. If the field is not present, the callback is not run. If
-// present, but null, the function is called with null set to true. Otherwise,
-// the function is called with the string for the user to conduct further
-// validation with.
-func (valid *Validation) NullableString(name string, fn func(s *string)) {
-	if valid.input == nil {
-		panic("Attempted to validate fields without input")
-	}
-	if o, ok := valid.input[name]; ok {
-		var val *string
-		if o != nil {
-			switch s := o.(type) {
-			case string:
-				val = &s
-			case *string:
-				val = s
-			default:
-				valid.
-					Error("Expected %s to be a string", name).
-					WithField(name)
-				return
-			}
-		}
-		fn(val)
-	}
-}
-
-// OptionalBool Fetches a boolean from the validation context, which must have an input
-// registered. If the field is not present, the callback is not run. If
-// present, but not a boolean, an error is recorded. Otherwise, the function is
-// called with the boolean for the user to conduct further validation with.
-func (valid *Validation) OptionalBool(name string, fn func(b bool)) {
-	if valid.input == nil {
-		panic("Attempted to validate fields without input")
-	}
-	if o, ok := valid.input[name]; ok {
-		if o == nil {
-			return
-		}
-		var val bool
-		switch b := o.(type) {
-		case bool:
-			val = b
-		case *bool:
-			val = *b
-		default:
-			valid.
-				Error("Expected %s to be a bool", name).
-				WithField(name)
-			return
-		}
-		fn(val)
-	}
-}
-
-// Creates a validation error unconditionally.
-func (valid *Validation) Error(msg string, items ...any) *ValidationError {
-	err := &gqlerror.Error{
-		Path:    graphql.GetPath(valid.ctx),
-		Message: fmt.Sprintf(msg, items...),
-	}
-	graphql.AddError(valid.ctx, err)
-	return &ValidationError{
-		valid: valid,
-		err:   err,
-	}
-}
-
-// Expect Asserts that a condition is true, recording a GraphQL error with the given
-// message if not.
-func (valid *Validation) Expect(cond bool, msg string, items ...any) *ValidationError {
-	if cond {
-		return &ValidationError{valid: valid}
-	}
-	return valid.Error(msg, items...)
-}
-
-// WithField Associates a field name with an error.
-func (err *ValidationError) WithField(field string) *ValidationError {
-	if err.err == nil {
-		return err
-	}
-	if err.err.Extensions == nil {
-		err.err.Extensions = make(map[string]any)
-	}
-	err.err.Extensions["field"] = field
-	return err
-}
-
-// WithCode Associates an error message with an error code.
-func (err *ValidationError) WithCode(code int) *ValidationError {
-	if err.err == nil {
-		return err
-	}
-	if err.err.Extensions == nil {
-		err.err.Extensions = make(map[string]any)
-	}
-	err.err.Extensions["code"] = code
-	return err
-}
-
-// And Composes another assertion onto the same validation context which initially
-// created an error. Short-circuiting is used, such that if the earlier
-// condition failed, the new condition is not considered.
-func (err *ValidationError) And(cond bool, msg string, items ...any) *ValidationError {
-	if err.err != nil {
-		return err
-	}
-	return err.valid.Expect(cond, msg, items...)
-}
-- 
2.54.0

