Skip to content

Commit 21d919a

Browse files
knoguchiclaude
andcommitted
Replace internal UUID with a canonical ULID ID type
internal/ids now defines ID ([16]byte): the domain identifier used across repository models, auth context, services, and tools. IDs render as ULIDs everywhere including logs; google/uuid remains only inside the ids package for the two storage edges that require UUID *formatting* of the same 128 bits — Postgres uuid columns (pgx encodes the underlying byte array transparently, no migration) and Qdrant collection names / point IDs (which reject ULID strings). All engine/vector-layer crossings explicitly use UUIDString(); struct- literal call sites that the initial sweep missed caused a namespace mismatch caught and fixed by live E2E. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a9e250c commit 21d919a

16 files changed

Lines changed: 188 additions & 162 deletions

File tree

server/cmd/ragd/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ func runRetentionReaper(ctx context.Context, tenantRepo repository.TenantReposit
249249
slog.Info("reaping expired tenant",
250250
"tenant_id", tenant.ID, "name", tenant.Name,
251251
"retention_days", tenant.Config.RetentionDays)
252-
if err := engine.DeleteNamespace(ctx, tenant.ID.String()); err != nil {
252+
if err := engine.DeleteNamespace(ctx, tenant.ID.UUIDString()); err != nil {
253253
slog.Warn("failed to delete expired tenant's vectors", "error", err, "tenant_id", tenant.ID)
254254
}
255255
if err := tenantRepo.Delete(ctx, tenant.ID); err != nil {

server/cmd/ragreindex/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func reindexTenant(
122122
tenant *repository.Tenant,
123123
contextual bool,
124124
) error {
125-
ns := tenant.ID.String()
125+
ns := tenant.ID.UUIDString()
126126
slog.Info("reindexing tenant", "tenant_id", ns, "name", tenant.Name)
127127

128128
// Recreate the collection as hybrid
@@ -192,7 +192,7 @@ func reindexDocument(
192192
}
193193
for _, c := range stored {
194194
chunks = append(chunks, ragcore.IngestedChunk{
195-
ID: c.ID.String(),
195+
ID: c.ID.UUIDString(),
196196
Index: c.ChunkIndex,
197197
Content: c.Content,
198198
Metadata: c.Metadata,
@@ -232,7 +232,7 @@ func reindexDocument(
232232
"source": doc.Source,
233233
"title": doc.Title,
234234
}
235-
if err := engine.IndexChunks(ctx, tenant.ID.String(), doc.ID.String(), chunks, defaults, true); err != nil {
235+
if err := engine.IndexChunks(ctx, tenant.ID.UUIDString(), doc.ID.UUIDString(), chunks, defaults, true); err != nil {
236236
return err
237237
}
238238

server/internal/auth/apikey.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"crypto/subtle"
77
"strings"
88

9-
"github.com/google/uuid"
9+
"github.com/knoguchi/rag/internal/ids"
1010
"github.com/knoguchi/rag/internal/repository"
1111
"google.golang.org/grpc"
1212
"google.golang.org/grpc/codes"
@@ -30,7 +30,7 @@ const (
3030

3131
// TenantInfo holds tenant information extracted from authentication
3232
type TenantInfo struct {
33-
ID uuid.UUID
33+
ID ids.ID
3434
Name string
3535
Config repository.TenantConfig
3636
}
@@ -252,10 +252,10 @@ func IsAdmin(ctx context.Context) bool {
252252
}
253253

254254
// TenantIDFromContext extracts just the tenant ID from context
255-
func TenantIDFromContext(ctx context.Context) (uuid.UUID, bool) {
255+
func TenantIDFromContext(ctx context.Context) (ids.ID, bool) {
256256
tenant, ok := TenantFromContext(ctx)
257257
if !ok {
258-
return uuid.Nil, false
258+
return ids.Nil, false
259259
}
260260
return tenant.ID, true
261261
}

server/internal/auth/apikey_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import (
44
"context"
55
"testing"
66

7-
"github.com/google/uuid"
7+
"github.com/knoguchi/rag/internal/ids"
88
"github.com/knoguchi/rag/internal/repository"
99
"google.golang.org/grpc"
1010
"google.golang.org/grpc/codes"
@@ -26,24 +26,24 @@ func (m *mockTenantRepo) GetByAPIKey(_ context.Context, apiKey string) (*reposit
2626
}
2727

2828
func (m *mockTenantRepo) Create(context.Context, *repository.Tenant, string) error { return nil }
29-
func (m *mockTenantRepo) GetByID(context.Context, uuid.UUID) (*repository.Tenant, error) {
29+
func (m *mockTenantRepo) GetByID(context.Context, ids.ID) (*repository.Tenant, error) {
3030
return nil, repository.ErrNotFound
3131
}
3232
func (m *mockTenantRepo) List(context.Context, int, int) ([]*repository.Tenant, int, error) {
3333
return nil, 0, nil
3434
}
35-
func (m *mockTenantRepo) Update(context.Context, *repository.Tenant) error { return nil }
36-
func (m *mockTenantRepo) Delete(context.Context, uuid.UUID) error { return nil }
37-
func (m *mockTenantRepo) UpdateAPIKey(context.Context, uuid.UUID, string) error { return nil }
38-
func (m *mockTenantRepo) UpdateUsage(context.Context, uuid.UUID, repository.TenantUsage) error {
35+
func (m *mockTenantRepo) Update(context.Context, *repository.Tenant) error { return nil }
36+
func (m *mockTenantRepo) Delete(context.Context, ids.ID) error { return nil }
37+
func (m *mockTenantRepo) UpdateAPIKey(context.Context, ids.ID, string) error { return nil }
38+
func (m *mockTenantRepo) UpdateUsage(context.Context, ids.ID, repository.TenantUsage) error {
3939
return nil
4040
}
4141
func (m *mockTenantRepo) ListExpired(context.Context) ([]*repository.Tenant, error) {
4242
return nil, nil
4343
}
4444

4545
func newTestInterceptor() (*APIKeyInterceptor, *repository.Tenant) {
46-
tenantID := uuid.New()
46+
tenantID := ids.New()
4747
tenant := &repository.Tenant{
4848
ID: tenantID,
4949
Name: "test-tenant",
@@ -234,7 +234,7 @@ func TestRequireTenant_NotInContext(t *testing.T) {
234234
}
235235

236236
func TestRequireTenant_InContext(t *testing.T) {
237-
tenant := &TenantInfo{ID: uuid.New(), Name: "test"}
237+
tenant := &TenantInfo{ID: ids.New(), Name: "test"}
238238
ctx := context.WithValue(context.Background(), tenantContextKey, tenant)
239239
got, err := RequireTenant(ctx)
240240
if err != nil {

server/internal/ids/ids.go

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
// Package ids standardizes external identifiers on ULID (26-char Crockford
2-
// base32, sortable by creation time). ULIDs and UUIDs are both 128 bits, so
3-
// internally everything remains uuid.UUID — Postgres uuid columns and Qdrant
4-
// collection names are untouched; only the API boundary speaks ULID.
1+
// Package ids defines the canonical 128-bit identifier used across the
2+
// system. IDs are ULIDs: generated sortable-by-creation, rendered as 26-char
3+
// Crockford base32 everywhere (APIs, logs). The two storage edges impose
4+
// UUID *formatting* on the same bytes: Postgres stores them in uuid columns
5+
// (pgx encodes the underlying [16]byte transparently) and Qdrant requires
6+
// UUID-shaped point IDs, so UUIDString()/ParseUUIDString convert at those
7+
// edges only.
58
package ids
69

710
import (
@@ -12,40 +15,71 @@ import (
1215
"github.com/oklog/ulid/v2"
1316
)
1417

15-
// New returns a fresh ULID-derived UUID.
16-
func New() uuid.UUID {
17-
return uuid.UUID(ulid.Make())
18+
// ID is the canonical 128-bit identifier. The zero value is Nil.
19+
type ID [16]byte
20+
21+
// Nil is the zero ID.
22+
var Nil ID
23+
24+
// New returns a fresh ULID.
25+
func New() ID {
26+
return ID(ulid.Make())
1827
}
1928

20-
// Format renders an internal UUID as its ULID string.
21-
func Format(id uuid.UUID) string {
29+
// String renders the ID as a ULID (26-char Crockford base32).
30+
func (id ID) String() string {
2231
return ulid.ULID(id).String()
2332
}
2433

34+
// IsNil reports whether the ID is the zero value.
35+
func (id ID) IsNil() bool {
36+
return id == Nil
37+
}
38+
39+
// UUIDString renders the same bytes in UUID form, for storage systems that
40+
// require UUID formatting (Qdrant point IDs and payloads).
41+
func (id ID) UUIDString() string {
42+
return uuid.UUID(id).String()
43+
}
44+
45+
// Format renders an ID as its ULID string.
46+
func Format(id ID) string {
47+
return id.String()
48+
}
49+
2550
// FormatString re-renders a UUID string as a ULID string; inputs that are
2651
// not UUIDs are returned unchanged (best-effort, for pass-through fields).
2752
func FormatString(s string) string {
28-
id, err := uuid.Parse(s)
53+
u, err := uuid.Parse(s)
2954
if err != nil {
3055
return s
3156
}
32-
return Format(id)
57+
return ID(u).String()
3358
}
3459

35-
// Parse accepts an identifier in either ULID (26 chars) or UUID form and
36-
// returns the internal UUID.
37-
func Parse(s string) (uuid.UUID, error) {
60+
// Parse accepts an identifier in either ULID (26 chars) or UUID form.
61+
func Parse(s string) (ID, error) {
3862
s = strings.TrimSpace(s)
3963
if len(s) == ulid.EncodedSize {
4064
u, err := ulid.ParseStrict(strings.ToUpper(s))
4165
if err != nil {
42-
return uuid.Nil, fmt.Errorf("invalid ULID: %w", err)
66+
return Nil, fmt.Errorf("invalid ULID: %w", err)
4367
}
44-
return uuid.UUID(u), nil
68+
return ID(u), nil
69+
}
70+
u, err := uuid.Parse(s)
71+
if err != nil {
72+
return Nil, fmt.Errorf("invalid ID (expected ULID or UUID): %w", err)
4573
}
46-
id, err := uuid.Parse(s)
74+
return ID(u), nil
75+
}
76+
77+
// ParseUUIDString converts a UUID-formatted string (as stored at the vector
78+
// layer) back to an ID.
79+
func ParseUUIDString(s string) (ID, error) {
80+
u, err := uuid.Parse(s)
4781
if err != nil {
48-
return uuid.Nil, fmt.Errorf("invalid ID (expected ULID or UUID): %w", err)
82+
return Nil, err
4983
}
50-
return id, nil
84+
return ID(u), nil
5185
}

server/internal/ids/ids_test.go

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,34 +22,29 @@ func TestRoundTrip(t *testing.T) {
2222
}
2323
}
2424

25-
func TestParse_AcceptsUUID(t *testing.T) {
26-
id := uuid.New()
27-
back, err := Parse(id.String())
28-
if err != nil {
29-
t.Fatalf("parse failed: %v", err)
25+
func TestParse_AcceptsUUIDAndLowercase(t *testing.T) {
26+
id := New()
27+
28+
back, err := Parse(id.UUIDString())
29+
if err != nil || back != id {
30+
t.Fatalf("uuid-form parse failed: %v (%s)", err, back)
3031
}
31-
if back != id {
32-
t.Errorf("expected %s, got %s", id, back)
32+
33+
back, err = Parse(strings.ToLower(id.String()))
34+
if err != nil || back != id {
35+
t.Fatalf("lowercase parse failed: %v (%s)", err, back)
3336
}
3437
}
3538

36-
func TestParse_AcceptsLowercaseULID(t *testing.T) {
39+
func TestUUIDStringRoundTrip(t *testing.T) {
3740
id := New()
38-
back, err := Parse(Format(id)[:26])
39-
if err != nil {
40-
t.Fatalf("parse failed: %v", err)
41-
}
42-
if back != id {
43-
t.Error("uppercase parse mismatch")
41+
u := id.UUIDString()
42+
if _, err := uuid.Parse(u); err != nil {
43+
t.Fatalf("UUIDString not a UUID: %v", err)
4444
}
45-
// lowercase form also accepted
46-
low := strings.ToLower(Format(id))
47-
back2, err := Parse(low)
48-
if err != nil {
49-
t.Fatalf("lowercase parse failed: %v", err)
50-
}
51-
if back2 != id {
52-
t.Error("lowercase parse mismatch")
45+
back, err := ParseUUIDString(u)
46+
if err != nil || back != id {
47+
t.Fatalf("uuid round trip failed: %v", err)
5348
}
5449
}
5550

@@ -65,8 +60,8 @@ func TestFormatString_PassThrough(t *testing.T) {
6560
if got := FormatString("not-an-id"); got != "not-an-id" {
6661
t.Errorf("expected pass-through, got %q", got)
6762
}
68-
id := uuid.New()
69-
if got := FormatString(id.String()); len(got) != 26 {
70-
t.Errorf("expected ULID form, got %q", got)
63+
id := New()
64+
if got := FormatString(id.UUIDString()); got != id.String() {
65+
t.Errorf("expected %s, got %q", id.String(), got)
7166
}
7267
}

server/internal/repository/postgres/crawljob.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"errors"
77
"fmt"
88

9-
"github.com/google/uuid"
109
"github.com/jackc/pgx/v5"
10+
"github.com/knoguchi/rag/internal/ids"
1111
"github.com/knoguchi/rag/internal/repository"
1212
)
1313

@@ -43,7 +43,7 @@ func (r *CrawlJobRepo) Create(ctx context.Context, job *repository.CrawlJob) err
4343
}
4444

4545
// GetByID retrieves a crawl job by ID
46-
func (r *CrawlJobRepo) GetByID(ctx context.Context, id uuid.UUID) (*repository.CrawlJob, error) {
46+
func (r *CrawlJobRepo) GetByID(ctx context.Context, id ids.ID) (*repository.CrawlJob, error) {
4747
query := `
4848
SELECT id, tenant_id, type, status, root_url, config, pages_crawled, pages_total, pages_failed, error_message, created_at, started_at, completed_at
4949
FROM crawl_jobs
@@ -72,7 +72,7 @@ func (r *CrawlJobRepo) GetByID(ctx context.Context, id uuid.UUID) (*repository.C
7272
}
7373

7474
// List retrieves crawl jobs for a tenant with pagination
75-
func (r *CrawlJobRepo) List(ctx context.Context, tenantID uuid.UUID, status string, limit, offset int) ([]*repository.CrawlJob, int, error) {
75+
func (r *CrawlJobRepo) List(ctx context.Context, tenantID ids.ID, status string, limit, offset int) ([]*repository.CrawlJob, int, error) {
7676
// Build query with optional status filter
7777
countQuery := `SELECT COUNT(*) FROM crawl_jobs WHERE tenant_id = $1`
7878
listQuery := `
@@ -184,7 +184,7 @@ func (r *CrawlJobRepo) UpdatePage(ctx context.Context, page *repository.CrawledP
184184
}
185185

186186
// GetPages retrieves pages for a crawl job
187-
func (r *CrawlJobRepo) GetPages(ctx context.Context, jobID uuid.UUID, status string, limit, offset int) ([]*repository.CrawledPage, int, error) {
187+
func (r *CrawlJobRepo) GetPages(ctx context.Context, jobID ids.ID, status string, limit, offset int) ([]*repository.CrawledPage, int, error) {
188188
// Build query with optional status filter
189189
countQuery := `SELECT COUNT(*) FROM crawled_pages WHERE job_id = $1`
190190
listQuery := `

server/internal/repository/postgres/document.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"errors"
77
"fmt"
88

9-
"github.com/google/uuid"
109
"github.com/jackc/pgx/v5"
10+
"github.com/knoguchi/rag/internal/ids"
1111
"github.com/knoguchi/rag/internal/repository"
1212
)
1313

@@ -43,7 +43,7 @@ func (r *DocumentRepo) Create(ctx context.Context, doc *repository.Document) err
4343
}
4444

4545
// GetByID retrieves a document by ID, scoped to a tenant
46-
func (r *DocumentRepo) GetByID(ctx context.Context, tenantID, id uuid.UUID) (*repository.Document, error) {
46+
func (r *DocumentRepo) GetByID(ctx context.Context, tenantID, id ids.ID) (*repository.Document, error) {
4747
query := `
4848
SELECT id, tenant_id, source, title, content_hash, chunk_count, status, error_message, metadata, created_at, updated_at
4949
FROM documents
@@ -53,7 +53,7 @@ func (r *DocumentRepo) GetByID(ctx context.Context, tenantID, id uuid.UUID) (*re
5353
}
5454

5555
// GetByHash retrieves a document by content hash for a tenant
56-
func (r *DocumentRepo) GetByHash(ctx context.Context, tenantID uuid.UUID, hash string) (*repository.Document, error) {
56+
func (r *DocumentRepo) GetByHash(ctx context.Context, tenantID ids.ID, hash string) (*repository.Document, error) {
5757
query := `
5858
SELECT id, tenant_id, source, title, content_hash, chunk_count, status, error_message, metadata, created_at, updated_at
5959
FROM documents
@@ -63,7 +63,7 @@ func (r *DocumentRepo) GetByHash(ctx context.Context, tenantID uuid.UUID, hash s
6363
}
6464

6565
// ListBySource returns all documents for a tenant with the given source
66-
func (r *DocumentRepo) ListBySource(ctx context.Context, tenantID uuid.UUID, source string) ([]*repository.Document, error) {
66+
func (r *DocumentRepo) ListBySource(ctx context.Context, tenantID ids.ID, source string) ([]*repository.Document, error) {
6767
query := `
6868
SELECT id, tenant_id, source, title, content_hash, chunk_count, status, error_message, metadata, created_at, updated_at
6969
FROM documents
@@ -119,7 +119,7 @@ func (r *DocumentRepo) scanDocument(ctx context.Context, query string, args ...a
119119
}
120120

121121
// List retrieves documents for a tenant with pagination
122-
func (r *DocumentRepo) List(ctx context.Context, tenantID uuid.UUID, status string, limit, offset int) ([]*repository.Document, int, error) {
122+
func (r *DocumentRepo) List(ctx context.Context, tenantID ids.ID, status string, limit, offset int) ([]*repository.Document, int, error) {
123123
// Build query with optional status filter
124124
countQuery := `SELECT COUNT(*) FROM documents WHERE tenant_id = $1`
125125
listQuery := `
@@ -197,7 +197,7 @@ func (r *DocumentRepo) Update(ctx context.Context, doc *repository.Document) err
197197
}
198198

199199
// Delete deletes a document, scoped to a tenant
200-
func (r *DocumentRepo) Delete(ctx context.Context, tenantID, id uuid.UUID) error {
200+
func (r *DocumentRepo) Delete(ctx context.Context, tenantID, id ids.ID) error {
201201
result, err := r.db.Pool.Exec(ctx, `DELETE FROM documents WHERE tenant_id = $1 AND id = $2`, tenantID, id)
202202
if err != nil {
203203
return fmt.Errorf("failed to delete document: %w", err)
@@ -239,7 +239,7 @@ func (r *DocumentRepo) CreateChunks(ctx context.Context, chunks []*repository.Do
239239
}
240240

241241
// GetChunks retrieves chunks for a document, scoped to a tenant
242-
func (r *DocumentRepo) GetChunks(ctx context.Context, tenantID, documentID uuid.UUID, limit, offset int) ([]*repository.DocumentChunk, error) {
242+
func (r *DocumentRepo) GetChunks(ctx context.Context, tenantID, documentID ids.ID, limit, offset int) ([]*repository.DocumentChunk, error) {
243243
query := `
244244
SELECT id, document_id, chunk_index, content, metadata, created_at
245245
FROM document_chunks
@@ -272,7 +272,7 @@ func (r *DocumentRepo) GetChunks(ctx context.Context, tenantID, documentID uuid.
272272
}
273273

274274
// DeleteChunks deletes all chunks for a document, scoped to a tenant
275-
func (r *DocumentRepo) DeleteChunks(ctx context.Context, tenantID, documentID uuid.UUID) error {
275+
func (r *DocumentRepo) DeleteChunks(ctx context.Context, tenantID, documentID ids.ID) error {
276276
_, err := r.db.Pool.Exec(ctx, `DELETE FROM document_chunks WHERE tenant_id = $1 AND document_id = $2`, tenantID, documentID)
277277
if err != nil {
278278
return fmt.Errorf("failed to delete chunks: %w", err)

0 commit comments

Comments
 (0)