Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/internal/httpapi/origins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func TestSplitOriginTrustAndRuntimeConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || !strings.Contains(string(body), `window.__CLICKCLACK_CONFIG__={"apiBaseUrl":"http://127.0.0.1:18081/services/clickclack"}`) {
if response.Code != http.StatusOK || !strings.Contains(string(body), `window.__CLICKCLACK_CONFIG__={"apiBaseUrl":"http://127.0.0.1:18081/services/clickclack","frontendBaseUrl":"http://127.0.0.1:18080"}`) {
t.Fatalf("expected injected API base, got %d %q", response.Code, body)
}
if response.Header().Get("Cache-Control") != "no-store" {
Expand Down
5 changes: 4 additions & 1 deletion apps/api/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1605,7 +1605,10 @@ func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
}

func (s *Server) injectRuntimeConfig(index []byte) []byte {
config, err := json.Marshal(map[string]string{"apiBaseUrl": s.publicAPIURL})
config, err := json.Marshal(map[string]string{
"apiBaseUrl": s.publicAPIURL,
"frontendBaseUrl": s.frontendURL,
})
if err != nil {
return index
}
Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/store/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,9 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu
if err := qtx.InsertThreadState(ctx, id); err != nil {
return store.Message{}, store.Event{}, err
}
if err := assignRouteIDTx(ctx, tx, "messages", id, 'M'); err != nil {
return store.Message{}, store.Event{}, err
}
eventFields := map[string]string{"message_id": id, "author_id": input.AuthorID}
if input.TopicID != "" {
eventFields["topic_id"] = input.TopicID
Expand Down
15 changes: 7 additions & 8 deletions apps/api/internal/store/postgres/route_ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import (

const (
routeIDInsertAttempts = 5
routeIDMigrationName = "0011_public_route_ids.sql"
routeIDBackfillMarker = "0011_public_route_ids.backfill"
// PostgreSQL shipped public route-ID columns in its initial schema, unlike
// SQLite where they arrived in the later 0011 migration. The backfill must
// therefore recognize an existing PostgreSQL deployment by its own schema
// marker.
routeIDMigrationName = "0001_schema.sql"
routeIDBackfillMarker = "0011_public_route_ids.message_citations_backfill"
)

func isRouteIDConflict(err error) bool {
Expand Down Expand Up @@ -58,12 +62,7 @@ func (s *Store) backfillRouteIDs(ctx context.Context) error {
return s.backfillTableRouteIDs(ctx, "messages", "M", `
route_id IS NULL
AND parent_message_id IS NULL
AND EXISTS (
SELECT 1
FROM thread_state ts
WHERE ts.root_message_id = messages.id
AND ts.reply_count > 0
)`)
AND channel_id IS NOT NULL`)
}

func (s *Store) backfillTableRouteIDs(ctx context.Context, table, prefix, where string) error {
Expand Down
170 changes: 170 additions & 0 deletions apps/api/internal/store/postgres/route_ids_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package postgres

import (
"context"
"database/sql"
"strings"
"testing"

"github.com/openclaw/clickclack/apps/api/internal/store"
)

func TestMigrateBackfillsLegacyChannelRootRouteIDs(t *testing.T) {
ctx := context.Background()
st := newIsolatedPostgresTestStore(t)
applyPostgresMigrationsBefore(t, ctx, st, "0002_default_workspace_owner.sql")

const (
ownerID = "usr_legacy_route_owner"
memberID = "usr_legacy_route_member"
workspaceID = "wsp_legacy_route"
channelID = "chn_legacy_route"
directID = "dct_legacy_route"
channelRootID = "msg_legacy_channel_root"
directRootID = "msg_legacy_direct_root"
replyID = "msg_legacy_channel_reply"
)
for _, statement := range []struct {
query string
args []any
}{
{`INSERT INTO users (id, display_name, avatar_url, created_at) VALUES ($1, 'Owner', '', $2)`, []any{ownerID, now()}},
{`INSERT INTO users (id, display_name, avatar_url, created_at) VALUES ($1, 'Member', '', $2)`, []any{memberID, now()}},
{`INSERT INTO workspaces (id, name, slug, created_at) VALUES ($1, 'Legacy Routes', 'legacy-routes', $2)`, []any{workspaceID, now()}},
{`INSERT INTO workspace_members (workspace_id, user_id, role, created_at) VALUES ($1, $2, 'owner', $3), ($1, $4, 'member', $3)`, []any{workspaceID, ownerID, now(), memberID}},
{`INSERT INTO channels (id, workspace_id, name, kind, created_at) VALUES ($1, $2, 'general', 'public', $3)`, []any{channelID, workspaceID, now()}},
{`INSERT INTO direct_conversations (id, workspace_id, created_at) VALUES ($1, $2, $3)`, []any{directID, workspaceID, now()}},
{`INSERT INTO messages (id, workspace_id, channel_id, author_id, thread_root_id, channel_seq, body, body_format, created_at) VALUES ($1, $2, $3, $4, $1, 1, 'channel root', 'markdown', $5)`, []any{channelRootID, workspaceID, channelID, ownerID, now()}},
{`INSERT INTO messages (id, workspace_id, direct_conversation_id, author_id, thread_root_id, channel_seq, body, body_format, created_at) VALUES ($1, $2, $3, $4, $1, 1, 'direct root', 'markdown', $5)`, []any{directRootID, workspaceID, directID, ownerID, now()}},
{`INSERT INTO messages (id, workspace_id, channel_id, author_id, parent_message_id, thread_root_id, thread_seq, body, body_format, created_at) VALUES ($1, $2, $3, $4, $5, $5, 1, 'reply', 'markdown', $6)`, []any{replyID, workspaceID, channelID, memberID, channelRootID, now()}},
} {
if _, err := st.db.ExecContext(ctx, statement.query, statement.args...); err != nil {
t.Fatal(err)
}
}

if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}

var workspaceRouteID, channelRouteID, directRouteID, channelRootRouteID, directRootRouteID, replyRouteID sql.NullString
for _, check := range []struct {
query string
id string
dest *sql.NullString
}{
{`SELECT route_id FROM workspaces WHERE id = $1`, workspaceID, &workspaceRouteID},
{`SELECT route_id FROM channels WHERE id = $1`, channelID, &channelRouteID},
{`SELECT route_id FROM direct_conversations WHERE id = $1`, directID, &directRouteID},
{`SELECT route_id FROM messages WHERE id = $1`, channelRootID, &channelRootRouteID},
{`SELECT route_id FROM messages WHERE id = $1`, directRootID, &directRootRouteID},
{`SELECT route_id FROM messages WHERE id = $1`, replyID, &replyRouteID},
} {
if err := st.db.QueryRowContext(ctx, check.query, check.id).Scan(check.dest); err != nil {
t.Fatal(err)
}
}
for _, check := range []struct {
name string
value sql.NullString
prefix string
}{
{"workspace", workspaceRouteID, "T"},
{"channel", channelRouteID, "C"},
{"direct conversation", directRouteID, "D"},
{"channel root", channelRootRouteID, "M"},
} {
if !check.value.Valid || !strings.HasPrefix(check.value.String, check.prefix) {
t.Fatalf("legacy %s was not backfilled: %q", check.name, check.value.String)
}
}
if directRootRouteID.Valid || replyRouteID.Valid {
t.Fatalf("legacy direct roots and replies must remain uncited: direct=%q reply=%q", directRootRouteID.String, replyRouteID.String)
}
var markerCount int
if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE name = $1`, routeIDBackfillMarker).Scan(&markerCount); err != nil {
t.Fatal(err)
}
if markerCount != 1 {
t.Fatalf("route ID backfill completion marker = %d, want 1", markerCount)
}
}

func TestMessageRouteIDCreationRespectsChannelAndDirectBoundaries(t *testing.T) {
ctx := context.Background()
st := newIsolatedPostgresTestStore(t)
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}

owner, err := st.CreateUser(ctx, store.CreateUserInput{
DisplayName: "Route Owner",
Email: "postgres-route-owner@example.com",
})
if err != nil {
t.Fatal(err)
}
member, err := st.CreateUser(ctx, store.CreateUserInput{
DisplayName: "Route Member",
Email: "postgres-route-member@example.com",
})
if err != nil {
t.Fatal(err)
}
workspace, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Route Boundaries"}, owner.ID)
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, store.WorkspaceRoleMember); err != nil {
t.Fatal(err)
}
channel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{
WorkspaceID: workspace.ID,
UserID: owner.ID,
Name: "route-boundaries",
Kind: "public",
})
if err != nil {
t.Fatal(err)
}

channelRoot, _, err := st.CreateMessage(ctx, store.CreateMessageInput{
ChannelID: channel.ID,
AuthorID: owner.ID,
Body: "channel root",
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(channelRoot.RouteID, "M") {
t.Fatalf("channel root should receive an eager M route_id: %#v", channelRoot)
}

direct, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{
WorkspaceID: workspace.ID,
UserID: owner.ID,
MemberIDs: []string{member.ID},
})
if err != nil {
t.Fatal(err)
}
directRoot, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{
ConversationID: direct.ID,
AuthorID: owner.ID,
Body: "direct root",
})
if err != nil {
t.Fatal(err)
}
if directRoot.RouteID != "" {
t.Fatalf("direct root should preserve lazy M route allocation: %#v", directRoot)
}

directRoot, err = st.EnsureThreadRouteID(ctx, owner.ID, directRoot.ID)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(directRoot.RouteID, "M") {
t.Fatalf("direct root should receive an M route_id only through the compatible thread path: %#v", directRoot)
}
}
9 changes: 2 additions & 7 deletions apps/api/internal/store/sqlite/route_ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
const (
routeIDInsertAttempts = 5
routeIDMigrationName = "0011_public_route_ids.sql"
routeIDBackfillMarker = "0011_public_route_ids.backfill"
routeIDBackfillMarker = "0011_public_route_ids.message_citations_backfill"
)

func isRouteIDConflict(err error) bool {
Expand Down Expand Up @@ -58,12 +58,7 @@ func (s *Store) backfillRouteIDs(ctx context.Context) error {
return s.backfillTableRouteIDs(ctx, "messages", "M", `
route_id IS NULL
AND parent_message_id IS NULL
AND EXISTS (
SELECT 1
FROM thread_state ts
WHERE ts.root_message_id = messages.id
AND ts.reply_count > 0
)`)
AND channel_id IS NOT NULL`)
}

func (s *Store) backfillTableRouteIDs(ctx context.Context, table, prefix, where string) error {
Expand Down
66 changes: 56 additions & 10 deletions apps/api/internal/store/sqlite/route_ids_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,19 @@ func TestRouteIDsCreationResolutionAndPermissions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
originalChannelRootRouteID := channelRoot.RouteID
if !hasRoutePrefix(originalChannelRootRouteID, "M") {
t.Fatalf("channel root should receive an eager M route_id: %#v", channelRoot)
}
if _, err := st.ResolveLegacyRouteTarget(ctx, owner.ID, otherWorkspace.ID, channelRoot.ID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected wrong workspace legacy thread route to fail closed, got %v", err)
}
channelRoot, err = getMessage(ctx, st.db, channelRoot.ID)
if err != nil {
t.Fatal(err)
}
if channelRoot.RouteID != "" {
t.Fatalf("wrong workspace legacy thread route should not assign M route_id: %#v", channelRoot)
if channelRoot.RouteID != originalChannelRootRouteID {
t.Fatalf("wrong workspace legacy thread route should not change M route_id: %#v", channelRoot)
}
}

Expand Down Expand Up @@ -201,9 +205,10 @@ func TestRouteTargetEdgesAndThreadCreationPaths(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if channelRoot.RouteID != "" {
t.Fatalf("new channel root should not eagerly get route_id: %#v", channelRoot)
if !hasRoutePrefix(channelRoot.RouteID, "M") {
t.Fatalf("new channel root should eagerly get an M route_id: %#v", channelRoot)
}
originalRouteID := channelRoot.RouteID
reply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: channelRoot.ID, AuthorID: owner.ID, Body: "first reply"})
if err != nil {
t.Fatal(err)
Expand All @@ -215,6 +220,12 @@ func TestRouteTargetEdgesAndThreadCreationPaths(t *testing.T) {
if !hasRoutePrefix(channelRoot.RouteID, "M") {
t.Fatalf("thread reply path should assign root route_id: %#v", channelRoot)
}
if channelRoot.RouteID != originalRouteID {
t.Fatalf("first reply should preserve the root route_id: %q vs %q", originalRouteID, channelRoot.RouteID)
}
if reply.RouteID != "" {
t.Fatalf("thread reply should not receive a route_id: %#v", reply)
}
channelThreadTarget, err := st.ResolveRouteTarget(ctx, owner.ID, workspace.RouteID, channelRoot.RouteID)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -319,7 +330,7 @@ func TestRouteIDFaultBranches(t *testing.T) {
}
}

func TestRouteIDBackfillOnlyExistingThreadRoots(t *testing.T) {
func TestRouteIDBackfillAllChannelRoots(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, channel := seededStore(t)

Expand All @@ -334,6 +345,29 @@ func TestRouteIDBackfillOnlyExistingThreadRoots(t *testing.T) {
if err != nil {
t.Fatal(err)
}
channelReply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: plainRoot.ID, AuthorID: owner.ID, Body: "plain reply"})
if err != nil {
t.Fatal(err)
}
other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "citation-backfill-other@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, other.ID, "member"); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{
WorkspaceID: workspace.ID,
UserID: owner.ID,
MemberIDs: []string{other.ID},
})
if err != nil {
t.Fatal(err)
}
dmRoot, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "private root"})
if err != nil {
t.Fatal(err)
}
mustExecSQL(t, ctx, st, `DROP TRIGGER messages_route_id_immutable`)
mustExecSQL(t, ctx, st, `DROP TRIGGER workspaces_route_id_immutable`)
mustExecSQL(t, ctx, st, `DROP TRIGGER channels_route_id_immutable`)
Expand All @@ -345,13 +379,19 @@ func TestRouteIDBackfillOnlyExistingThreadRoots(t *testing.T) {
t.Fatal(err)
}

var threadRouteID, plainRouteID, workspaceRouteID, channelRouteID sql.NullString
var threadRouteID, plainRouteID, replyRouteID, dmRootRouteID, workspaceRouteID, channelRouteID sql.NullString
if err := st.db.QueryRowContext(ctx, `SELECT route_id FROM messages WHERE id = ?`, threadRoot.ID).Scan(&threadRouteID); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRowContext(ctx, `SELECT route_id FROM messages WHERE id = ?`, plainRoot.ID).Scan(&plainRouteID); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRowContext(ctx, `SELECT route_id FROM messages WHERE id = ?`, channelReply.ID).Scan(&replyRouteID); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRowContext(ctx, `SELECT route_id FROM messages WHERE id = ?`, dmRoot.ID).Scan(&dmRootRouteID); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRowContext(ctx, `SELECT route_id FROM workspaces WHERE id = ?`, workspace.ID).Scan(&workspaceRouteID); err != nil {
t.Fatal(err)
}
Expand All @@ -361,8 +401,14 @@ func TestRouteIDBackfillOnlyExistingThreadRoots(t *testing.T) {
if !threadRouteID.Valid || !hasRoutePrefix(threadRouteID.String, "M") {
t.Fatalf("thread root was not backfilled: %q", threadRouteID.String)
}
if plainRouteID.Valid {
t.Fatalf("plain root should not be backfilled with M route_id: %q", plainRouteID.String)
if !plainRouteID.Valid || !hasRoutePrefix(plainRouteID.String, "M") {
t.Fatalf("plain channel root was not backfilled with M route_id: %q", plainRouteID.String)
}
if replyRouteID.Valid {
t.Fatalf("channel reply should not be backfilled with M route_id: %q", replyRouteID.String)
}
if dmRootRouteID.Valid {
t.Fatalf("direct-message root should not be backfilled with M route_id: %q", dmRootRouteID.String)
}
if !workspaceRouteID.Valid || !hasRoutePrefix(workspaceRouteID.String, "T") {
t.Fatalf("workspace was not backfilled: %q", workspaceRouteID.String)
Expand Down Expand Up @@ -423,8 +469,8 @@ func TestMigrateBackfillsRouteIDsOnce(t *testing.T) {
if !threadRouteID.Valid || !hasRoutePrefix(threadRouteID.String, "M") {
t.Fatalf("thread root was not backfilled: %q", threadRouteID.String)
}
if plainRouteID.Valid {
t.Fatalf("plain root should not receive a route_id during 0011 backfill: %q", plainRouteID.String)
if !plainRouteID.Valid || !hasRoutePrefix(plainRouteID.String, "M") {
t.Fatalf("plain channel root should receive a route_id during citation backfill: %q", plainRouteID.String)
}
if scalarCount(t, ctx, st, `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, routeIDBackfillMarker) != 1 {
t.Fatal("route ID backfill completion marker was not recorded")
Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/store/sqlite/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,9 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu
if err := qtx.InsertThreadState(ctx, id); err != nil {
return store.Message{}, store.Event{}, err
}
if err := assignRouteIDTx(ctx, tx, "messages", id, 'M'); err != nil {
return store.Message{}, store.Event{}, err
}
eventFields := map[string]string{"message_id": id, "author_id": input.AuthorID}
if input.TopicID != "" {
eventFields["topic_id"] = input.TopicID
Expand Down
Loading