ubiquitous-invention/apps/web/server/routers/search.ts

227 lines
6.1 KiB
TypeScript
Raw Permalink Normal View History

import { z } from "zod";
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { objects } from "@tasks/database/schema";
import { objectTypes } from "@tasks/shared";
import type { Context } from "@/server/trpc";
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
import { router, workspaceProcedure } from "@/server/trpc";
const objectTypeSchema = z.enum(objectTypes);
function escapeIlike(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
function makeSnippet(
description: string | null,
query: string,
maxLen = 160,
): string | null {
if (!description) return null;
const t = description.trim();
if (!t) return null;
const q = query.trim().toLowerCase();
if (!q) {
return t.length > maxLen ? `${t.slice(0, maxLen)}` : t;
}
const lower = t.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) {
return t.length > maxLen ? `${t.slice(0, maxLen)}` : t;
}
const pad = 48;
const start = Math.max(0, idx - pad);
const end = Math.min(t.length, idx + query.length + pad);
let s = t.slice(start, end);
if (start > 0) s = `${s}`;
if (end < t.length) s = `${s}`;
return s;
}
type ParentRow = { parentId: string | null; title: string };
export type SearchResult = {
id: string;
type: string;
title: string;
status: string | null;
parentId: string | null;
workspaceId: string | null;
descriptionSnippet: string | null;
parentBreadcrumb: string | null;
};
async function fetchAncestorMap(
db: Context["db"],
parentIds: string[],
): Promise<Map<string, ParentRow>> {
const map = new Map<string, ParentRow>();
const seen = new Set<string>();
let frontier = [...new Set(parentIds)];
while (frontier.length > 0) {
const batch = await db
.select({
id: objects.id,
parentId: objects.parentId,
title: objects.title,
})
.from(objects)
.where(inArray(objects.id, frontier));
const next: string[] = [];
for (const row of batch) {
map.set(row.id, { parentId: row.parentId, title: row.title });
seen.add(row.id);
if (row.parentId && !seen.has(row.parentId)) {
next.push(row.parentId);
}
}
frontier = [...new Set(next.filter((id) => !seen.has(id)))];
}
return map;
}
function parentBreadcrumb(
parentId: string | null,
ancestorMap: Map<string, ParentRow>,
): string | null {
if (!parentId) return null;
const parts: string[] = [];
let cur: string | null = parentId;
for (let i = 0; i < 32 && cur; i++) {
const row = ancestorMap.get(cur);
if (!row) break;
parts.unshift(row.title);
cur = row.parentId;
}
return parts.length > 0 ? parts.join(" > ") : null;
}
export const searchRouter = router({
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
search: workspaceProcedure
.input(
z.object({
query: z.string(),
type: objectTypeSchema.optional(),
limit: z.number().int().positive().max(100).optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 20;
const raw = input.query.trim();
if (raw.length === 0) {
return { results: [] as SearchResult[] };
}
const pattern = `%${escapeIlike(raw)}%`;
const matchCondition = sql`(${objects.title} ILIKE ${pattern} ESCAPE '\\' OR ${objects.description} ILIKE ${pattern} ESCAPE '\\')`;
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
const conditions = [
eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt),
matchCondition,
];
if (input.type !== undefined) {
conditions.push(eq(objects.type, input.type));
}
const titleFirst = sql<number>`
CASE
WHEN ${objects.title} ILIKE ${pattern} ESCAPE '\\' THEN 0
ELSE 1
END
`.mapWith(Number);
const rows = await ctx.db
.select({
id: objects.id,
type: objects.type,
title: objects.title,
status: objects.status,
parentId: objects.parentId,
workspaceId: objects.workspaceId,
description: objects.description,
})
.from(objects)
.where(and(...conditions))
.orderBy(titleFirst, desc(objects.updatedAt))
.limit(limit);
const parentIds = rows
.map((r) => r.parentId)
.filter((x): x is string => x != null);
const ancestorMap =
parentIds.length > 0
? await fetchAncestorMap(ctx.db, parentIds)
: new Map<string, ParentRow>();
const results: SearchResult[] = rows.map((r) => ({
id: r.id,
type: r.type,
title: r.title,
status: r.status,
parentId: r.parentId,
workspaceId: r.workspaceId,
descriptionSnippet: makeSnippet(r.description, raw),
parentBreadcrumb: parentBreadcrumb(r.parentId, ancestorMap),
}));
return { results };
}),
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
recent: workspaceProcedure
.input(
z.object({
limit: z.number().int().positive().max(50).optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 10;
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
const conditions = [
eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt),
];
const rows = await ctx.db
.select({
id: objects.id,
type: objects.type,
title: objects.title,
status: objects.status,
parentId: objects.parentId,
workspaceId: objects.workspaceId,
description: objects.description,
})
.from(objects)
.where(and(...conditions))
.orderBy(desc(objects.updatedAt))
.limit(limit);
const parentIds = rows
.map((r) => r.parentId)
.filter((x): x is string => x != null);
const ancestorMap =
parentIds.length > 0
? await fetchAncestorMap(ctx.db, parentIds)
: new Map<string, ParentRow>();
const results: SearchResult[] = rows.map((r) => ({
id: r.id,
type: r.type,
title: r.title,
status: r.status,
parentId: r.parentId,
workspaceId: r.workspaceId,
descriptionSnippet: makeSnippet(r.description, ""),
parentBreadcrumb: parentBreadcrumb(r.parentId, ancestorMap),
}));
return { results };
}),
});