user-context migration
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m21s

This commit is contained in:
2026-03-24 18:46:36 -05:00
parent d33a838aec
commit 52786dcd44
8 changed files with 12590 additions and 108 deletions
+596
View File
@@ -0,0 +1,596 @@
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<string>();
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<string>();
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<ShareKind, SharePolicy> = {
user: "collaborative",
bucket: "handoff",
note: "handoff"
};
const getNoteSharePolicy = (note: Pick<NoteRecord, "tags"> | 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<string, string> = {}) => {
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<string, unknown>) =>
request(`/api/collections/${collection}/records`, {
method: "POST",
body: JSON.stringify(body)
});
const updateRecord = (collection: string, id: string, body: Record<string, unknown>) =>
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<string, NoteRef[]>();
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);
});