Files
podman/backend/src/pods/store.ts
T
Yahya Alhinai 454fdab13b chore(box): snapshot in-progress auth + user-learning WIP before deploy
Captures uncommitted work present on the live droplet so origin/main can be integrated and the new control toolbar deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:51:21 +00:00

258 lines
8.7 KiB
TypeScript

import type { Pod, PodInput } from '@podman/shared';
import { collections, getDb, type UserPodContextDoc } from '../memory/db.js';
const NO_ID = { projection: { _id: 0 } } as const;
const MAX_NAME = 120;
const MAX_REPO = 140;
const MAX_DESCRIPTION = 2000;
const MAX_MEMBERS = 50;
const MAX_MEMBER_LEN = 80;
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/** Validate + normalize a string field. Throws (-> 400) on type/length errors. */
function str(value: unknown, field: string, max: number, required = false): string | undefined {
if (value === undefined || value === null) {
if (required) throw new Error(`${field} is required`);
return undefined;
}
if (typeof value !== 'string') throw new Error(`${field} must be a string`);
const trimmed = value.trim();
if (required && !trimmed) throw new Error(`${field} is required`);
if (trimmed.length > max) throw new Error(`${field} too long (max ${max})`);
return trimmed;
}
/** Validate, trim, length-cap, de-dupe (case-insensitive), and limit count. */
function cleanMembers(members: unknown): string[] {
if (members === undefined || members === null) return [];
if (!Array.isArray(members)) throw new Error('members must be an array');
const seen = new Set<string>();
const out: string[] = [];
for (const raw of members) {
if (typeof raw !== 'string') throw new Error('member names must be strings');
const name = raw.trim();
if (name.length > MAX_MEMBER_LEN)
throw new Error(`member name too long (max ${MAX_MEMBER_LEN})`);
const key = name.toLowerCase();
if (name && !seen.has(key)) {
seen.add(key);
out.push(name);
}
}
if (out.length > MAX_MEMBERS) throw new Error(`too many members (max ${MAX_MEMBERS})`);
return out;
}
function isDuplicateKey(err: unknown): boolean {
return (err as { code?: number })?.code === 11000;
}
function now(): string {
return new Date().toISOString();
}
function regexExact(value: string): RegExp {
return new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i');
}
function profileString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function matchesMember(doc: UserPodContextDoc, member: string): boolean {
const key = member.trim().toLowerCase();
return [doc.memberName, doc.metadata?.identity, doc.metadata?.email]
.filter(Boolean)
.some((value) => String(value).trim().toLowerCase() === key);
}
async function hydrateMemberProfiles(pod: Pod): Promise<Pod> {
if (!pod.members.length) return pod;
const db = await getDb();
const matchers = pod.members.map(regexExact);
const docs = await db
.collection<UserPodContextDoc>('user_pod_context')
.find(
{
$or: [
{ memberName: { $in: matchers } },
{ 'metadata.identity': { $in: matchers } },
{ 'metadata.email': { $in: matchers } },
],
},
{ projection: { _id: 0 } },
)
.sort({ observedAt: -1 })
.limit(500)
.toArray();
const memberProfiles: NonNullable<Pod['memberProfiles']> = {};
for (const member of pod.members) {
const doc = docs.find((row) => matchesMember(row, member));
const imageUrl = profileString(doc?.metadata?.imageUrl);
if (!doc || !imageUrl) continue;
memberProfiles[member] = {
displayName: doc.memberName ?? member,
email: profileString(doc.metadata?.email),
imageUrl,
};
}
return Object.keys(memberProfiles).length ? { ...pod, memberProfiles } : pod;
}
async function hydratePods(pods: Pod[]): Promise<Pod[]> {
return Promise.all(pods.map((pod) => hydrateMemberProfiles(pod)));
}
export async function listPods(): Promise<Pod[]> {
const c = await collections();
return hydratePods(await c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray());
}
export async function getPod(id: string): Promise<Pod | null> {
const c = await collections();
const pod = await c.pods.findOne({ id }, NO_ID);
return pod ? hydrateMemberProfiles(pod) : null;
}
export async function createPod(input: PodInput): Promise<Pod> {
const name = str(input.name, 'name', MAX_NAME, true)!;
const repo = str(input.repo, 'repo', MAX_REPO) ?? '';
const description = str(input.description, 'description', MAX_DESCRIPTION);
const members = cleanMembers(input.members);
const c = await collections();
const ts = now();
const base = slugify(name) || 'pod';
// Atomic create: let the unique index arbitrate slug collisions. On a
// duplicate-key error, bump the suffix and retry — no check-then-insert race.
for (let suffix = 1; suffix <= MAX_MEMBERS + 50; suffix++) {
const pod: Pod = {
id: suffix === 1 ? base : `${base}-${suffix}`,
name,
repo,
description: description || undefined,
members,
createdAt: ts,
updatedAt: ts,
};
try {
await c.pods.insertOne({ ...pod });
return hydrateMemberProfiles(pod);
} catch (err) {
if (isDuplicateKey(err)) continue;
throw err;
}
}
throw new Error('could not allocate a unique pod id');
}
export async function updatePod(id: string, patch: PodInput): Promise<Pod | null> {
const set: Partial<Pod> = { updatedAt: now() };
if (patch.name !== undefined) {
const name = str(patch.name, 'name', MAX_NAME);
if (!name) throw new Error('name cannot be empty');
set.name = name;
}
if (patch.repo !== undefined) set.repo = str(patch.repo, 'repo', MAX_REPO) ?? '';
if (patch.description !== undefined) {
set.description = str(patch.description, 'description', MAX_DESCRIPTION) || undefined;
}
if (patch.members !== undefined) set.members = cleanMembers(patch.members);
const c = await collections();
const updated = await c.pods.findOneAndUpdate(
{ id },
{ $set: set },
{ returnDocument: 'after', projection: { _id: 0 } },
);
return updated ? hydrateMemberProfiles(updated) : null;
}
export async function deletePod(id: string): Promise<boolean> {
const c = await collections();
const r = await c.pods.deleteOne({ id });
return r.deletedCount > 0;
}
async function setMembers(id: string, members: string[]): Promise<Pod | null> {
const c = await collections();
const updated = await c.pods.findOneAndUpdate(
{ id },
{ $set: { members, updatedAt: now() } },
{ returnDocument: 'after', projection: { _id: 0 } },
);
return updated ? hydrateMemberProfiles(updated) : null;
}
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
if (typeof rawName !== 'string') throw new Error('member name must be a string');
const name = rawName.trim();
if (!name) throw new Error('member name is required');
if (name.length > MAX_MEMBER_LEN) throw new Error(`member name too long (max ${MAX_MEMBER_LEN})`);
const pod = await getPod(id);
if (!pod) return null;
if (pod.members.some((m) => m.toLowerCase() === name.toLowerCase())) return pod;
if (pod.members.length >= MAX_MEMBERS) throw new Error(`too many members (max ${MAX_MEMBERS})`);
return setMembers(id, [...pod.members, name]);
}
export async function removeMember(id: string, rawName: string): Promise<Pod | null> {
const name = String(rawName).trim().toLowerCase();
const pod = await getPod(id);
if (!pod) return null;
const filtered = pod.members.filter((m) => m.toLowerCase() !== name);
if (filtered.length === pod.members.length) return pod; // no-op: don't write / bump updatedAt
return setMembers(id, filtered);
}
/**
* Seed the default pods into a fresh DB. Idempotent and race-safe: gated on an
* empty collection, and uses per-doc upserts so concurrent startups can't
* create duplicates. Won't resurrect a default a user later deletes.
*/
export async function seedDefaultPods(): Promise<void> {
const c = await collections();
if ((await c.pods.estimatedDocumentCount()) > 0) return;
const ts = now();
const defaults: Pod[] = [
{
id: 'demo-pod',
name: 'Demo Pod',
repo: 'karti-ai/podman',
description: 'The full crew — used for the live demo.',
members: ['Karti', 'Yahya', 'Ramis', 'Zander', 'Shakthi'],
createdAt: ts,
updatedAt: ts,
},
{
id: 'frontend-pod',
name: 'Frontend Pod',
repo: 'karti-ai/podman',
description: 'PWA, capture, and PodMan card UI.',
members: ['Karti', 'Zander'],
createdAt: ts,
updatedAt: ts,
},
{
id: 'backend-pod',
name: 'Backend Pod',
repo: 'karti-ai/podman',
description: 'LiveKit agent, vision, collision detector.',
members: ['Yahya', 'Ramis'],
createdAt: ts,
updatedAt: ts,
},
];
await Promise.all(
defaults.map((p) => c.pods.updateOne({ id: p.id }, { $setOnInsert: p }, { upsert: true })),
);
console.log('[pods] seeded default pods');
}