import fs from "node:fs/promises"; import path from "node:path"; type SharePolicy = "collaborative" | "handoff"; type ShareKind = "user" | "bucket" | "note"; type ShareContext = { id: string; key: string; displayName: string; kind: ShareKind; policy: SharePolicy; targetUserId?: string | null; deletedAt?: string | null; }; type NoteRecord = { id: string; title: string; key: string; tags: string[]; user: string; tasks: string[]; }; type TaskShareRef = { contextId: string; key: string; kind: ShareKind; policy?: SharePolicy; }; type NoteRef = { noteId: string; key: string; }; type ParsedTag = | { kind: "share"; raw: string; key: string; display: string } | { kind: "note"; raw: string; key: string; display: string } | { kind: "label"; raw: string; key: string; display: string }; type TaskRecord = { id: string; title: string; tags?: string[]; labelTags?: string[]; shareRefs?: TaskShareRef[]; noteRefs?: NoteRef[]; createdBy?: string | null; user?: string | string[] | null; }; type Summary = { touched: number; personalRefsAdded: number; personalRefsRemoved: number; ambiguousMultiPersonal: number; failures: number; }; const mode = (process.argv[2] || "report").toLowerCase(); if (!["backup", "report", "migrate"].includes(mode)) { throw new Error("Usage: bun run scripts/context-ownership-migration.ts [backup|report|migrate]"); } const dataMode = (process.env.TASGRID_DATA_MODE || "prod").toLowerCase() === "dev" ? "dev" : "prod"; const withDataSuffix = (base: string) => dataMode === "dev" ? `${base}_Dev` : base; const TASGRID_COLLECTION = withDataSuffix("TasGrid"); const CONTEXTS_COLLECTION = withDataSuffix("TasGrid_Contexts"); const NOTES_COLLECTION = withDataSuffix("TasGrid_Notes"); const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__"; const POCKETBASE_URL = process.env.POCKETBASE_URL || "https://pocketbase.ccllc.pro"; let authToken = ""; const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " "); const normalizeKey = (value: string) => normalizeWhitespace(value) .toLowerCase() .replace(/^[@#]+/, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`; const buildNoteTag = (value: string) => `#${normalizeWhitespace(value)}`; const buildUserContextKey = (userId: string) => `user-${userId}`; const unwrapRelationId = (value: unknown): string | null => { if (typeof value === "string") return value || null; if (Array.isArray(value)) { const first = value[0]; return typeof first === "string" ? first : null; } return null; }; const parseTag = (rawTag: string): ParsedTag => { const raw = normalizeWhitespace(rawTag); const prefix = raw[0]; const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw; const key = normalizeKey(display); if (prefix === "@") return { kind: "share", raw, key, display }; if (prefix === "#") return { kind: "note", raw, key, display }; return { kind: "label", raw, key, display }; }; const parseTags = (rawTags: string[] | undefined | null) => (rawTags || []).map(parseTag).filter(tag => !!tag.display); const dedupeShareRefs = (refs: TaskShareRef[]) => { const seen = new Set(); return refs.filter(ref => { const key = `${ref.kind}:${ref.contextId}:${ref.key}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; const dedupeNoteRefs = (refs: NoteRef[]) => { const seen = new Set(); return refs.filter(ref => { const key = `${ref.noteId}:${ref.key}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; const DEFAULT_SHARE_POLICY_BY_KIND: Record = { user: "collaborative", bucket: "handoff", note: "handoff" }; const getNoteSharePolicy = (note: Pick | undefined): SharePolicy => note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative"; const relationId = (record: any, field: string) => unwrapRelationId(record?.[field]); const createOutputDir = async () => { const outputDir = path.resolve(process.cwd(), "migration-output", `context-ownership-${new Date().toISOString().replace(/[:.]/g, "-")}`); await fs.mkdir(outputDir, { recursive: true }); return outputDir; }; const tryAuthEndpoint = async (pathname: string, email: string, password: string) => { const response = await fetch(`${POCKETBASE_URL}${pathname}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: email, password }) }); if (!response.ok) { return { ok: false as const, status: response.status, body: await response.text(), pathname }; } return { ok: true as const, payload: await response.json(), pathname }; }; const auth = async () => { const email = process.env.PB_ADMIN_EMAIL || process.env.PB_EMAIL; const password = process.env.PB_ADMIN_PASSWORD || process.env.PB_PASSWORD; if (!email || !password) { throw new Error("Set PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD (or PB_EMAIL/PB_PASSWORD) before running this script."); } const authAttempts = [ await tryAuthEndpoint("/api/collections/_superusers/auth-with-password", email, password), await tryAuthEndpoint("/api/admins/auth-with-password", email, password) ]; const success = authAttempts.find(attempt => attempt.ok); if (!success) { const details = authAttempts .map(attempt => `${attempt.pathname}: ${attempt.status} ${attempt.body}`) .join(" | "); throw new Error(`PocketBase superuser/admin auth failed. ${details}`); } const payload = success.payload; authToken = payload.token; }; const request = async (pathname: string, init: RequestInit = {}) => { const response = await fetch(`${POCKETBASE_URL}${pathname}`, { ...init, headers: { "Content-Type": "application/json", ...(authToken ? { Authorization: authToken } : {}), ...(init.headers || {}) } }); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); } if (response.status === 204) { return null; } return response.json(); }; const getFullList = async (collection: string, query: Record = {}) => { const items: any[] = []; let page = 1; while (true) { const search = new URLSearchParams({ perPage: "500", page: String(page), ...query }); const payload = await request(`/api/collections/${collection}/records?${search.toString()}`); items.push(...(payload.items || [])); if (!payload.totalPages || page >= payload.totalPages) { break; } page += 1; } return items; }; const createRecord = (collection: string, body: Record) => request(`/api/collections/${collection}/records`, { method: "POST", body: JSON.stringify(body) }); const updateRecord = (collection: string, id: string, body: Record) => request(`/api/collections/${collection}/records/${id}`, { method: "PATCH", body: JSON.stringify(body) }); const syncUserContexts = async (users: any[], contexts: ShareContext[]) => { const nextContexts = [...contexts]; for (const user of users) { const displayName = user.name || user.email; const existing = nextContexts.find(context => context.kind === "user" && context.targetUserId === user.id && !context.deletedAt ); if (!displayName || existing) continue; const created = await createRecord(CONTEXTS_COLLECTION, { key: buildUserContextKey(user.id), displayName, kind: "user", policy: "collaborative", targetUserId: user.id }); nextContexts.push({ id: created.id, key: created.key, displayName: created.displayName, kind: created.kind, policy: "collaborative", targetUserId: relationId(created, "targetUserId"), deletedAt: created.deletedAt || null }); } return nextContexts; }; const buildNoteLinkMap = (notes: NoteRecord[]) => { const links = new Map(); for (const note of notes) { for (const taskId of note.tasks || []) { const existing = links.get(taskId) || []; existing.push({ noteId: note.id, key: note.key }); links.set(taskId, existing); } } return links; }; const main = async () => { await auth(); const [users, rawContexts, rawNotes, rawTasks] = await Promise.all([ getFullList("users", { fields: "id,name,email" }), getFullList(CONTEXTS_COLLECTION), getFullList(NOTES_COLLECTION), getFullList(TASGRID_COLLECTION) ]); const contexts = mode === "migrate" ? await syncUserContexts(users, rawContexts.map((record: any) => ({ id: record.id, key: record.key, displayName: record.displayName, kind: record.kind || "bucket", policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy, targetUserId: relationId(record, "targetUserId"), deletedAt: record.deletedAt || null }))) : rawContexts.map((record: any) => ({ id: record.id, key: record.key, displayName: record.displayName, kind: record.kind || "bucket", policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy, targetUserId: relationId(record, "targetUserId"), deletedAt: record.deletedAt || null })); const notes = rawNotes.map((record: any) => ({ id: record.id, title: record.title, key: record.key || normalizeKey(record.title || record.id), tags: Array.isArray(record.tags) ? record.tags.filter(Boolean) : [], user: relationId(record, "user") || "", tasks: Array.isArray(record.tasks) ? record.tasks.filter(Boolean) : [] })) satisfies NoteRecord[]; const contextById = new Map(contexts.map(context => [context.id, context])); const ownerContextByUserId = new Map( contexts .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) .map(context => [context.targetUserId as string, context] as const) ); const noteById = new Map(notes.map(note => [note.id, note])); const noteByKey = new Map(notes.map(note => [note.key, note])); const noteLinksByTaskId = buildNoteLinkMap(notes); const getContextByRef = (ref: TaskShareRef) => { if (ref.kind === "note") { const note = noteById.get(ref.contextId) || noteByKey.get(ref.key); return note ? { id: note.id, key: note.key, displayName: note.key || note.title, kind: "note" as const, policy: getNoteSharePolicy(note), targetUserId: note.user } : null; } return contextById.get(ref.contextId) || contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) || contexts.find(context => !context.deletedAt && normalizeKey(context.displayName) === ref.key) || null; }; const getTaskSharePolicy = (ref: TaskShareRef): SharePolicy => { if (ref.kind === "note") { if (ref.policy) return ref.policy; const note = noteById.get(ref.contextId) || noteByKey.get(ref.key); return getNoteSharePolicy(note); } const context = getContextByRef(ref); if (ref.policy) return ref.policy; return context?.policy || DEFAULT_SHARE_POLICY_BY_KIND[ref.kind]; }; const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => { if (ref.kind !== "user") return null; const context = getContextByRef(ref); if (context?.targetUserId) return context.targetUserId; if (ref.key.startsWith("user-")) return ref.key.replace(/^user-/, "") || null; return null; }; const getTaskPersonalRefs = (refs: TaskShareRef[]) => refs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref)); const resolveMirroredOwnerId = (currentOwnerId: string | null, refs: TaskShareRef[], preferredOwnerId?: string | null) => { const personalUserIds = [...new Set( refs .map(ref => getPersonalContextUserIdFromRef(ref)) .filter((value): value is string => !!value) )]; if (personalUserIds.length === 1) return personalUserIds[0]; if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) return preferredOwnerId; return currentOwnerId || null; }; const buildTaskTags = (labelTags: string[], shareRefs: TaskShareRef[], noteRefs: NoteRef[], favoriteTag?: string) => { const tags = [ ...labelTags, ...shareRefs.map(ref => { const context = getContextByRef(ref); if (ref.kind === "note") return buildNoteTag(context?.key || ref.key); if (context?.displayName) return buildShareTag(context.displayName); if (ref.kind === "user" && ref.key.startsWith("user-")) return null; return buildShareTag(ref.key); }), ...noteRefs.map(ref => { const note = noteById.get(ref.noteId) || noteByKey.get(ref.key); return buildNoteTag(note?.key || ref.key); }) ]; if (favoriteTag && !tags.includes(favoriteTag)) tags.push(favoriteTag); return [...new Set(tags.filter((value): value is string => !!value))]; }; const summary: Summary = { touched: 0, personalRefsAdded: 0, personalRefsRemoved: 0, ambiguousMultiPersonal: 0, failures: 0 }; const reportRows: any[] = []; for (const record of rawTasks as TaskRecord[]) { if ((record.tags || []).includes("__template__")) continue; try { const legacyTags = record.tags || []; const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__"); const shareRefs = Array.isArray(record.shareRefs) ? record.shareRefs.map(ref => ({ contextId: ref.contextId, key: ref.key, kind: ref.kind, policy: ref.policy })) : parsedTags .filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note") .map(tag => { if (tag.kind === "note") { const note = noteByKey.get(tag.key); return { contextId: note?.id || tag.key, key: tag.key, kind: "note" as const }; } const context = contexts.find(existing => !existing.deletedAt && ( existing.key === tag.key || normalizeKey(existing.displayName) === tag.key || buildShareTag(existing.displayName).toLowerCase() === tag.raw.toLowerCase() ) ); return { contextId: context?.id || tag.key, key: tag.key, kind: (context?.kind || "bucket") as "user" | "bucket", policy: context?.policy }; }); const noteRefs = dedupeNoteRefs([ ...(Array.isArray(record.noteRefs) ? record.noteRefs.map(ref => ({ noteId: ref.noteId, key: ref.key })) : parsedTags .filter(tag => tag.kind === "note") .map(tag => { const note = noteByKey.get(tag.key); return { noteId: note?.id || tag.key, key: tag.key }; })), ...(noteLinksByTaskId.get(record.id) || []) ]); let nextShareRefs = dedupeShareRefs([ ...shareRefs, ...noteRefs.map(ref => ({ contextId: ref.noteId, key: ref.key, kind: "note" as const, policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy })) ]); const ownerId = unwrapRelationId(record.user); const ownerContext = ownerId ? ownerContextByUserId.get(ownerId) : undefined; const shouldRemoveOwnerPersonalRef = !!ownerId && getTaskPersonalRefs(nextShareRefs).length === 0 && nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff"); if (ownerContext) { const matchesOwnerContext = (ref: TaskShareRef) => ref.kind === "user" && ( ref.contextId === ownerContext.id || ref.key === ownerContext.key || normalizeKey(ownerContext.displayName) === ref.key ); if (shouldRemoveOwnerPersonalRef) { const before = nextShareRefs.length; nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref)); if (before !== nextShareRefs.length) { summary.personalRefsRemoved += before - nextShareRefs.length; } } else if (!nextShareRefs.some(matchesOwnerContext)) { nextShareRefs = [ { contextId: ownerContext.id, key: ownerContext.key, kind: "user" as const, policy: ownerContext.policy }, ...nextShareRefs ]; summary.personalRefsAdded += 1; } } nextShareRefs = dedupeShareRefs(nextShareRefs); const labelTags = Array.isArray(record.labelTags) ? record.labelTags : parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); const favoriteTag = legacyTags.find(tag => tag.endsWith("_favorite__")); const nextTags = buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag); const nextOwnerId = resolveMirroredOwnerId(ownerId, nextShareRefs, ownerId); const personalUserIds = [...new Set(nextShareRefs.map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))]; if (personalUserIds.length > 1) { summary.ambiguousMultiPersonal += 1; } const changed = JSON.stringify(nextShareRefs) !== JSON.stringify(record.shareRefs || []) || JSON.stringify(noteRefs) !== JSON.stringify(record.noteRefs || []) || JSON.stringify(nextTags) !== JSON.stringify(record.tags || []) || nextOwnerId !== ownerId; if (changed) { summary.touched += 1; reportRows.push({ id: record.id, title: record.title, ownerIdBefore: ownerId, ownerIdAfter: nextOwnerId, personalUserIds }); if (mode === "migrate") { await updateRecord(TASGRID_COLLECTION, record.id, { user: nextOwnerId, tags: nextTags, labelTags, shareRefs: nextShareRefs, noteRefs }); } } } catch (error) { summary.failures += 1; reportRows.push({ id: record.id, title: record.title, error: error instanceof Error ? error.message : String(error) }); } } if (mode === "backup") { const outputDir = await createOutputDir(); await Promise.all([ fs.writeFile(path.join(outputDir, "users.json"), JSON.stringify(users, null, 2)), fs.writeFile(path.join(outputDir, "contexts.json"), JSON.stringify(contexts, null, 2)), fs.writeFile(path.join(outputDir, "notes.json"), JSON.stringify(rawNotes, null, 2)), fs.writeFile(path.join(outputDir, "tasks.json"), JSON.stringify(rawTasks, null, 2)) ]); console.log(JSON.stringify({ mode, dataMode, outputDir, counts: { users: users.length, contexts: contexts.length, notes: rawNotes.length, tasks: rawTasks.length } }, null, 2)); return; } if (mode === "report") { const outputDir = await createOutputDir(); await fs.writeFile(path.join(outputDir, "context-ownership-report.json"), JSON.stringify({ summary, tasks: reportRows }, null, 2)); console.log(JSON.stringify({ mode, dataMode, outputDir, summary }, null, 2)); return; } console.log(JSON.stringify({ mode, dataMode, summary }, null, 2)); }; main().catch((error) => { console.error(error); process.exit(1); });