chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@mosaicstack/brain",
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "packages/brain"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/db": "workspace:^",
|
||||
"@mosaicstack/types": "workspace:*",
|
||||
"commander": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { eq, and, or, type Db, agents } from '@mosaicstack/db';
|
||||
|
||||
export type Agent = typeof agents.$inferSelect;
|
||||
export type NewAgent = typeof agents.$inferInsert;
|
||||
|
||||
export function createAgentsRepo(db: Db) {
|
||||
return {
|
||||
async findAll(): Promise<Agent[]> {
|
||||
return db.select().from(agents);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Agent | undefined> {
|
||||
const rows = await db.select().from(agents).where(eq(agents.id, id));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByName(name: string): Promise<Agent | undefined> {
|
||||
const rows = await db.select().from(agents).where(eq(agents.name, name));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByProject(projectId: string): Promise<Agent[]> {
|
||||
return db.select().from(agents).where(eq(agents.projectId, projectId));
|
||||
},
|
||||
|
||||
async findSystem(): Promise<Agent[]> {
|
||||
return db.select().from(agents).where(eq(agents.isSystem, true));
|
||||
},
|
||||
|
||||
/**
|
||||
* Return only agents the user may access: their own agents plus all system agents.
|
||||
* Never returns other users' private agents.
|
||||
*/
|
||||
async findAccessible(ownerId: string): Promise<Agent[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(or(eq(agents.ownerId, ownerId), eq(agents.isSystem, true)));
|
||||
},
|
||||
|
||||
async create(data: NewAgent): Promise<Agent> {
|
||||
const rows = await db.insert(agents).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an agent.
|
||||
*
|
||||
* For user-owned agents pass `ownerId` — the WHERE clause will enforce ownership so that
|
||||
* one user cannot overwrite another user's agent. For system agents the caller must
|
||||
* omit `ownerId` (admin-only path) and the WHERE clause only matches on `id`.
|
||||
*
|
||||
* Returns undefined when no row was matched (not found or ownership mismatch).
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<NewAgent>,
|
||||
ownerId?: string,
|
||||
): Promise<Agent | undefined> {
|
||||
const condition =
|
||||
ownerId !== undefined
|
||||
? and(eq(agents.id, id), eq(agents.ownerId, ownerId))
|
||||
: eq(agents.id, id);
|
||||
|
||||
const rows = await db
|
||||
.update(agents)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(condition)
|
||||
.returning();
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a user-owned agent, scoped to the given owner.
|
||||
* Will not match system agents even if the id is correct, because system agents have
|
||||
* `ownerId = null` which cannot equal a real user id.
|
||||
* Returns false when no row was matched (not found, wrong owner, or system agent).
|
||||
*/
|
||||
async remove(id: string, ownerId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(agents)
|
||||
.where(and(eq(agents.id, id), eq(agents.ownerId, ownerId)))
|
||||
.returning();
|
||||
return rows.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentsRepo = ReturnType<typeof createAgentsRepo>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { createProjectsRepo, type ProjectsRepo } from './projects.js';
|
||||
import { createMissionsRepo, type MissionsRepo } from './missions.js';
|
||||
import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js';
|
||||
import { createTasksRepo, type TasksRepo } from './tasks.js';
|
||||
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
||||
import { createAgentsRepo, type AgentsRepo } from './agents.js';
|
||||
|
||||
export interface Brain {
|
||||
projects: ProjectsRepo;
|
||||
missions: MissionsRepo;
|
||||
missionTasks: MissionTasksRepo;
|
||||
tasks: TasksRepo;
|
||||
conversations: ConversationsRepo;
|
||||
agents: AgentsRepo;
|
||||
}
|
||||
|
||||
export function createBrain(db: Db): Brain {
|
||||
return {
|
||||
projects: createProjectsRepo(db),
|
||||
missions: createMissionsRepo(db),
|
||||
missionTasks: createMissionTasksRepo(db),
|
||||
tasks: createTasksRepo(db),
|
||||
conversations: createConversationsRepo(db),
|
||||
agents: createAgentsRepo(db),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { registerBrainCommand } from './cli.js';
|
||||
|
||||
/**
|
||||
* Smoke test: verifies the command tree is correctly registered.
|
||||
* No database connection is opened — we only inspect Commander metadata.
|
||||
*/
|
||||
describe('registerBrainCommand', () => {
|
||||
function buildProgram(): Command {
|
||||
const program = new Command('mosaic');
|
||||
// Prevent Commander from calling process.exit on parse errors during tests.
|
||||
program.exitOverride();
|
||||
registerBrainCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('registers a top-level "brain" command', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain');
|
||||
expect(brainCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('registers "brain projects" with "list" and "create" subcommands', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||
const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects');
|
||||
expect(projectsCmd).toBeDefined();
|
||||
|
||||
const subNames = projectsCmd!.commands.map((c) => c.name());
|
||||
expect(subNames).toContain('list');
|
||||
expect(subNames).toContain('create');
|
||||
});
|
||||
|
||||
it('registers "brain missions" with "list" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||
const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions');
|
||||
expect(missionsCmd).toBeDefined();
|
||||
|
||||
const subNames = missionsCmd!.commands.map((c) => c.name());
|
||||
expect(subNames).toContain('list');
|
||||
});
|
||||
|
||||
it('registers "brain tasks" with "list" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||
const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks');
|
||||
expect(tasksCmd).toBeDefined();
|
||||
|
||||
const subNames = tasksCmd!.commands.map((c) => c.name());
|
||||
expect(subNames).toContain('list');
|
||||
});
|
||||
|
||||
it('registers "brain conversations" with "list" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||
const conversationsCmd = brainCmd.commands.find((c) => c.name() === 'conversations');
|
||||
expect(conversationsCmd).toBeDefined();
|
||||
|
||||
const subNames = conversationsCmd!.commands.map((c) => c.name());
|
||||
expect(subNames).toContain('list');
|
||||
});
|
||||
|
||||
it('"brain projects list" accepts --db and --limit options', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||
const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects')!;
|
||||
const listCmd = projectsCmd.commands.find((c) => c.name() === 'list')!;
|
||||
|
||||
const optionNames = listCmd.options.map((o) => o.long);
|
||||
expect(optionNames).toContain('--db');
|
||||
expect(optionNames).toContain('--limit');
|
||||
});
|
||||
|
||||
it('"brain missions list" accepts --project option', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||
const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions')!;
|
||||
const listCmd = missionsCmd.commands.find((c) => c.name() === 'list')!;
|
||||
|
||||
const optionNames = listCmd.options.map((o) => o.long);
|
||||
expect(optionNames).toContain('--project');
|
||||
});
|
||||
|
||||
it('"brain tasks list" accepts --project option', () => {
|
||||
const program = buildProgram();
|
||||
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||
const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks')!;
|
||||
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list')!;
|
||||
|
||||
const optionNames = listCmd.options.map((o) => o.long);
|
||||
expect(optionNames).toContain('--project');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Command } from 'commander';
|
||||
import { createDb, type DbHandle } from '@mosaicstack/db';
|
||||
import { createBrain } from './brain.js';
|
||||
|
||||
/**
|
||||
* Build and attach the `brain` subcommand tree onto an existing Commander program.
|
||||
* Uses the caller's Command instance to avoid cross-package Commander version mismatches.
|
||||
*/
|
||||
export function registerBrainCommand(parent: Command): void {
|
||||
const brain = parent.command('brain').description('Inspect and manage brain data stores');
|
||||
|
||||
// ─── shared DB option helper ─────────────────────────────────────────────
|
||||
|
||||
function addDbOption(cmd: Command): Command {
|
||||
return cmd.option(
|
||||
'--db <connection-string>',
|
||||
'PostgreSQL connection string (overrides MOSAIC_DB_URL)',
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDb(opts: { db?: string }): ReturnType<typeof createBrain> {
|
||||
const connectionString = opts.db ?? process.env['MOSAIC_DB_URL'];
|
||||
if (!connectionString) {
|
||||
console.error('No DB connection string provided. Pass --db <url> or set MOSAIC_DB_URL.');
|
||||
process.exit(1);
|
||||
}
|
||||
const handle: DbHandle = createDb(connectionString);
|
||||
return createBrain(handle.db);
|
||||
}
|
||||
|
||||
// ─── projects ────────────────────────────────────────────────────────────
|
||||
|
||||
const projects = brain.command('projects').description('Manage projects');
|
||||
|
||||
addDbOption(
|
||||
projects
|
||||
.command('list')
|
||||
.description('List all projects')
|
||||
.option('--limit <n>', 'Maximum number of results', '50'),
|
||||
).action(async (opts: { db?: string; limit: string }) => {
|
||||
const b = resolveDb(opts);
|
||||
const limit = parseInt(opts.limit, 10);
|
||||
const rows = await b.projects.findAll();
|
||||
const sliced = rows.slice(0, limit);
|
||||
if (sliced.length === 0) {
|
||||
console.log('No projects found.');
|
||||
return;
|
||||
}
|
||||
for (const p of sliced) {
|
||||
console.log(`${p.id} ${p.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
addDbOption(
|
||||
projects
|
||||
.command('create <name>')
|
||||
.description('Create a new project')
|
||||
.requiredOption('--owner-id <id>', 'Owner user ID'),
|
||||
).action(async (name: string, opts: { db?: string; ownerId: string }) => {
|
||||
const b = resolveDb(opts);
|
||||
const created = await b.projects.create({
|
||||
name,
|
||||
ownerId: opts.ownerId,
|
||||
ownerType: 'user',
|
||||
});
|
||||
console.log(`Created project: ${created.id} ${created.name}`);
|
||||
});
|
||||
|
||||
// ─── missions ────────────────────────────────────────────────────────────
|
||||
|
||||
const missions = brain.command('missions').description('Manage missions');
|
||||
|
||||
addDbOption(
|
||||
missions
|
||||
.command('list')
|
||||
.description('List all missions')
|
||||
.option('--limit <n>', 'Maximum number of results', '50')
|
||||
.option('--project <id>', 'Filter by project ID'),
|
||||
).action(async (opts: { db?: string; limit: string; project?: string }) => {
|
||||
const b = resolveDb(opts);
|
||||
const limit = parseInt(opts.limit, 10);
|
||||
const rows = opts.project
|
||||
? await b.missions.findByProject(opts.project)
|
||||
: await b.missions.findAll();
|
||||
const sliced = rows.slice(0, limit);
|
||||
if (sliced.length === 0) {
|
||||
console.log('No missions found.');
|
||||
return;
|
||||
}
|
||||
for (const m of sliced) {
|
||||
console.log(`${m.id} ${m.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── tasks ────────────────────────────────────────────────────────────────
|
||||
|
||||
const tasks = brain.command('tasks').description('Manage generic tasks');
|
||||
|
||||
addDbOption(
|
||||
tasks
|
||||
.command('list')
|
||||
.description('List all tasks')
|
||||
.option('--limit <n>', 'Maximum number of results', '50')
|
||||
.option('--project <id>', 'Filter by project ID'),
|
||||
).action(async (opts: { db?: string; limit: string; project?: string }) => {
|
||||
const b = resolveDb(opts);
|
||||
const limit = parseInt(opts.limit, 10);
|
||||
const rows = opts.project ? await b.tasks.findByProject(opts.project) : await b.tasks.findAll();
|
||||
const sliced = rows.slice(0, limit);
|
||||
if (sliced.length === 0) {
|
||||
console.log('No tasks found.');
|
||||
return;
|
||||
}
|
||||
for (const t of sliced) {
|
||||
console.log(`${t.id} ${t.title} [${t.status}]`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── conversations ────────────────────────────────────────────────────────
|
||||
|
||||
const conversations = brain.command('conversations').description('Manage conversations');
|
||||
|
||||
addDbOption(
|
||||
conversations
|
||||
.command('list')
|
||||
.description('List conversations for a user')
|
||||
.option('--limit <n>', 'Maximum number of results', '50')
|
||||
.requiredOption('--user-id <id>', 'User ID to scope the query'),
|
||||
).action(async (opts: { db?: string; limit: string; userId: string }) => {
|
||||
const b = resolveDb(opts);
|
||||
const limit = parseInt(opts.limit, 10);
|
||||
const rows = await b.conversations.findAll(opts.userId);
|
||||
const sliced = rows.slice(0, limit);
|
||||
if (sliced.length === 0) {
|
||||
console.log('No conversations found.');
|
||||
return;
|
||||
}
|
||||
for (const c of sliced) {
|
||||
console.log(`${c.id} ${c.title ?? '(untitled)'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { eq, and, asc, desc, ilike, type Db, conversations, messages } from '@mosaicstack/db';
|
||||
|
||||
/** Maximum number of conversations returned per list query. */
|
||||
const MAX_CONVERSATIONS = 200;
|
||||
/** Maximum number of messages returned per conversation history query. */
|
||||
const MAX_MESSAGES = 500;
|
||||
|
||||
export type Conversation = typeof conversations.$inferSelect;
|
||||
export type NewConversation = typeof conversations.$inferInsert;
|
||||
export type Message = typeof messages.$inferSelect;
|
||||
export type NewMessage = typeof messages.$inferInsert;
|
||||
|
||||
export interface MessageSearchResult {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
conversationTitle: string | null;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export function createConversationsRepo(db: Db) {
|
||||
return {
|
||||
async findAll(userId: string): Promise<Conversation[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(eq(conversations.userId, userId))
|
||||
.orderBy(desc(conversations.updatedAt))
|
||||
.limit(MAX_CONVERSATIONS);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find a conversation by ID, scoped to the given user.
|
||||
* Returns undefined if the conversation does not exist or belongs to a different user.
|
||||
*/
|
||||
async findById(id: string, userId: string): Promise<Conversation | undefined> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async create(data: NewConversation): Promise<Conversation> {
|
||||
const rows = await db.insert(conversations).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a conversation, scoped to the given user.
|
||||
* Returns undefined if the conversation does not exist or belongs to a different user.
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<NewConversation>,
|
||||
): Promise<Conversation | undefined> {
|
||||
const rows = await db
|
||||
.update(conversations)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)))
|
||||
.returning();
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a conversation, scoped to the given user.
|
||||
* Returns false if the conversation does not exist or belongs to a different user.
|
||||
*/
|
||||
async remove(id: string, userId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(conversations)
|
||||
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)))
|
||||
.returning();
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find messages for a conversation, scoped to the given user.
|
||||
* Returns an empty array if the conversation does not exist or belongs to a different user.
|
||||
*/
|
||||
async findMessages(conversationId: string, userId: string): Promise<Message[]> {
|
||||
// Verify ownership of the parent conversation before returning messages.
|
||||
const conv = await db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId)));
|
||||
if (conv.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, conversationId))
|
||||
.orderBy(asc(messages.createdAt))
|
||||
.limit(MAX_MESSAGES);
|
||||
},
|
||||
|
||||
/**
|
||||
* Search messages by content across all conversations belonging to the user.
|
||||
* Uses ILIKE for case-insensitive substring matching.
|
||||
*/
|
||||
async searchMessages(
|
||||
userId: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<MessageSearchResult[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
messageId: messages.id,
|
||||
conversationId: conversations.id,
|
||||
conversationTitle: conversations.title,
|
||||
role: messages.role,
|
||||
content: messages.content,
|
||||
createdAt: messages.createdAt,
|
||||
})
|
||||
.from(messages)
|
||||
.innerJoin(conversations, eq(messages.conversationId, conversations.id))
|
||||
.where(and(eq(conversations.userId, userId), ilike(messages.content, `%${query}%`)))
|
||||
.orderBy(desc(messages.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a message to a conversation, scoped to the given user.
|
||||
* Verifies the parent conversation belongs to the user before inserting.
|
||||
* Returns undefined if the conversation does not exist or belongs to a different user.
|
||||
*/
|
||||
async addMessage(data: NewMessage, userId: string): Promise<Message | undefined> {
|
||||
// Verify ownership of the parent conversation before inserting the message.
|
||||
const conv = await db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, data.conversationId), eq(conversations.userId, userId)));
|
||||
if (conv.length === 0) return undefined;
|
||||
|
||||
const rows = await db.insert(messages).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ConversationsRepo = ReturnType<typeof createConversationsRepo>;
|
||||
@@ -0,0 +1,36 @@
|
||||
export { createBrain, type Brain } from './brain.js';
|
||||
export { registerBrainCommand } from './cli.js';
|
||||
export {
|
||||
createProjectsRepo,
|
||||
type ProjectsRepo,
|
||||
type Project,
|
||||
type NewProject,
|
||||
} from './projects.js';
|
||||
export {
|
||||
createMissionsRepo,
|
||||
type MissionsRepo,
|
||||
type Mission,
|
||||
type NewMission,
|
||||
} from './missions.js';
|
||||
export {
|
||||
createMissionTasksRepo,
|
||||
type MissionTasksRepo,
|
||||
type MissionTask,
|
||||
type NewMissionTask,
|
||||
} from './mission-tasks.js';
|
||||
export { createTasksRepo, type TasksRepo, type Task, type NewTask } from './tasks.js';
|
||||
export {
|
||||
createConversationsRepo,
|
||||
type ConversationsRepo,
|
||||
type Conversation,
|
||||
type NewConversation,
|
||||
type Message,
|
||||
type NewMessage,
|
||||
type MessageSearchResult,
|
||||
} from './conversations.js';
|
||||
export {
|
||||
createAgentsRepo,
|
||||
type AgentsRepo,
|
||||
type Agent as AgentConfig,
|
||||
type NewAgent as NewAgentConfig,
|
||||
} from './agents.js';
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createMissionTasksRepo } from './mission-tasks.js';
|
||||
|
||||
/**
|
||||
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
|
||||
* the sole path that authors mission_tasks.status from caller input (storage
|
||||
* tier migration is row transport and preserves stored values; the generic
|
||||
* storage adapters have no mission_tasks caller), and it must never forward a
|
||||
* caller-supplied status to the database on create or update. Callers keep
|
||||
* working (the field is accepted and ignored), so these tests assert on what
|
||||
* reaches the Drizzle chain, not on rejection.
|
||||
*/
|
||||
|
||||
function makeInsertDb(returned: unknown[]) {
|
||||
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
|
||||
return { db: { insert: vi.fn(() => ({ values })) }, values };
|
||||
}
|
||||
|
||||
function makeUpdateDb(returned: unknown[]) {
|
||||
const set = vi.fn((_v: unknown) => ({
|
||||
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
|
||||
}));
|
||||
return { db: { update: vi.fn(() => ({ set })) }, set };
|
||||
}
|
||||
|
||||
describe('createMissionTasksRepo — status write prohibition', () => {
|
||||
it('create strips a caller-supplied status before insert', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.create({
|
||||
missionId: 'm1',
|
||||
userId: 'u1',
|
||||
status: 'done',
|
||||
description: 'd',
|
||||
} as never);
|
||||
|
||||
expect(values).toHaveBeenCalledTimes(1);
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
expect(inserted.missionId).toBe('m1');
|
||||
expect(inserted.description).toBe('d');
|
||||
expect(result.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('create without status still inserts (DB default applies)', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
|
||||
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
});
|
||||
|
||||
it('update strips a caller-supplied status but keeps the other fields', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in updated).toBe(false);
|
||||
expect(updated.notes).toBe('n');
|
||||
expect(updated.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result?.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('update with only status degenerates to a timestamp-only update', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.update('mt1', { status: 'blocked' } as never);
|
||||
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(Object.keys(updated)).toEqual(['updatedAt']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
|
||||
|
||||
export type MissionTask = typeof missionTasks.$inferSelect;
|
||||
export type NewMissionTask = typeof missionTasks.$inferInsert;
|
||||
|
||||
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
|
||||
// write source through the N-1 window. This repo is the sole path that authors
|
||||
// status from caller input, so the field is stripped here — accepted and
|
||||
// ignored rather than rejected, because the legacy surface is frozen with
|
||||
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
|
||||
// surfaces touch the column and are deliberately NOT stripped:
|
||||
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
|
||||
// must preserve the stored value verbatim, and the generic table-keyed storage
|
||||
// adapters register mission_tasks but have no caller that targets it (runtime
|
||||
// callers use fixed collection constants). Neither authors a new status. The
|
||||
// column keeps its DB default, stays declared and readable, and is retired
|
||||
// only after no readers remain.
|
||||
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
|
||||
const rest = { ...data };
|
||||
delete rest.status;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function createMissionTasksRepo(db: Db) {
|
||||
return {
|
||||
async findByMission(missionId: string): Promise<MissionTask[]> {
|
||||
return db.select().from(missionTasks).where(eq(missionTasks.missionId, missionId));
|
||||
},
|
||||
|
||||
async findByMissionAndUser(missionId: string, userId: string): Promise<MissionTask[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(missionTasks)
|
||||
.where(and(eq(missionTasks.missionId, missionId), eq(missionTasks.userId, userId)));
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<MissionTask | undefined> {
|
||||
const rows = await db.select().from(missionTasks).where(eq(missionTasks.id, id));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByIdAndUser(id: string, userId: string): Promise<MissionTask | undefined> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(missionTasks)
|
||||
.where(and(eq(missionTasks.id, id), eq(missionTasks.userId, userId)));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async create(data: NewMissionTask): Promise<MissionTask> {
|
||||
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
|
||||
const rows = await db
|
||||
.update(missionTasks)
|
||||
.set({ ...stripStatus(data), updatedAt: new Date() })
|
||||
.where(eq(missionTasks.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const rows = await db.delete(missionTasks).where(eq(missionTasks.id, id)).returning();
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async removeByMission(missionId: string): Promise<number> {
|
||||
const rows = await db
|
||||
.delete(missionTasks)
|
||||
.where(eq(missionTasks.missionId, missionId))
|
||||
.returning();
|
||||
return rows.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MissionTasksRepo = ReturnType<typeof createMissionTasksRepo>;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { eq, and, type Db, missions } from '@mosaicstack/db';
|
||||
|
||||
export type Mission = typeof missions.$inferSelect;
|
||||
export type NewMission = typeof missions.$inferInsert;
|
||||
|
||||
export function createMissionsRepo(db: Db) {
|
||||
return {
|
||||
async findAll(): Promise<Mission[]> {
|
||||
return db.select().from(missions);
|
||||
},
|
||||
|
||||
async findAllByUser(userId: string): Promise<Mission[]> {
|
||||
return db.select().from(missions).where(eq(missions.userId, userId));
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Mission | undefined> {
|
||||
const rows = await db.select().from(missions).where(eq(missions.id, id));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByIdAndUser(id: string, userId: string): Promise<Mission | undefined> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(missions)
|
||||
.where(and(eq(missions.id, id), eq(missions.userId, userId)));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByProject(projectId: string): Promise<Mission[]> {
|
||||
return db.select().from(missions).where(eq(missions.projectId, projectId));
|
||||
},
|
||||
|
||||
async findByProjectAndUser(projectId: string, userId: string): Promise<Mission[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(missions)
|
||||
.where(and(eq(missions.projectId, projectId), eq(missions.userId, userId)));
|
||||
},
|
||||
|
||||
async create(data: NewMission): Promise<Mission> {
|
||||
const rows = await db.insert(missions).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewMission>): Promise<Mission | undefined> {
|
||||
const rows = await db
|
||||
.update(missions)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(missions.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const rows = await db.delete(missions).where(eq(missions.id, id)).returning();
|
||||
return rows.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MissionsRepo = ReturnType<typeof createMissionsRepo>;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createProjectsRepo } from './projects.js';
|
||||
|
||||
/**
|
||||
* Build a minimal Drizzle mock. Each call to db.select() returns a fresh
|
||||
* chain that resolves `where()` to the provided rows for that call.
|
||||
*
|
||||
* `calls` is an ordered list: the first item is returned for the first
|
||||
* db.select() call, the second for the second, and so on.
|
||||
*/
|
||||
function makeDb(calls: unknown[][]) {
|
||||
let callIndex = 0;
|
||||
const selectSpy = vi.fn(() => {
|
||||
const rows = calls[callIndex++] ?? [];
|
||||
const chain = {
|
||||
where: vi.fn().mockResolvedValue(rows),
|
||||
} as { where: ReturnType<typeof vi.fn>; from?: ReturnType<typeof vi.fn> };
|
||||
// from() returns the chain so .where() can be chained, but also resolves
|
||||
// directly (as a thenable) for queries with no .where() call.
|
||||
chain.from = vi.fn(() => Object.assign(Promise.resolve(rows), chain));
|
||||
return chain;
|
||||
});
|
||||
return { select: selectSpy };
|
||||
}
|
||||
|
||||
describe('createProjectsRepo — findAllForUser', () => {
|
||||
it('filters by userId when user has no team memberships', async () => {
|
||||
// First select: teamMembers query → empty
|
||||
// Second select: projects query → one owned project
|
||||
const db = makeDb([
|
||||
[], // teamMembers rows
|
||||
[{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' }],
|
||||
]);
|
||||
const repo = createProjectsRepo(db as never);
|
||||
|
||||
const result = await repo.findAllForUser('user-1');
|
||||
|
||||
expect(db.select).toHaveBeenCalledTimes(2);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe('p1');
|
||||
});
|
||||
|
||||
it('includes team projects when user is a team member', async () => {
|
||||
// First select: teamMembers → user belongs to one team
|
||||
// Second select: projects query → two projects (own + team)
|
||||
const db = makeDb([
|
||||
[{ teamId: 'team-1' }],
|
||||
[
|
||||
{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' },
|
||||
{ id: 'p2', ownerId: null, teamId: 'team-1', ownerType: 'team' },
|
||||
],
|
||||
]);
|
||||
const repo = createProjectsRepo(db as never);
|
||||
|
||||
const result = await repo.findAllForUser('user-1');
|
||||
|
||||
expect(db.select).toHaveBeenCalledTimes(2);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty array when user has no projects and no teams', async () => {
|
||||
const db = makeDb([[], []]);
|
||||
const repo = createProjectsRepo(db as never);
|
||||
|
||||
const result = await repo.findAllForUser('user-no-projects');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProjectsRepo — findAll', () => {
|
||||
it('returns all rows without any user filter', async () => {
|
||||
const rows = [
|
||||
{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' },
|
||||
{ id: 'p2', ownerId: 'user-2', teamId: null, ownerType: 'user' },
|
||||
];
|
||||
const db = makeDb([rows]);
|
||||
const repo = createProjectsRepo(db as never);
|
||||
|
||||
const result = await repo.findAll();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { eq, or, inArray, type Db, projects, teamMembers } from '@mosaicstack/db';
|
||||
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
|
||||
export function createProjectsRepo(db: Db) {
|
||||
return {
|
||||
async findAll(): Promise<Project[]> {
|
||||
return db.select().from(projects);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return only the projects visible to a given user:
|
||||
* – projects directly owned by the user (ownerType = 'user', ownerId = userId), OR
|
||||
* – projects owned by a team the user belongs to (ownerType = 'team', teamId IN user's teams)
|
||||
*/
|
||||
async findAllForUser(userId: string): Promise<Project[]> {
|
||||
// Fetch the team IDs the user is a member of.
|
||||
const memberRows = await db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
|
||||
if (teamIds.length === 0) {
|
||||
// No team memberships — return only directly owned projects.
|
||||
return db.select().from(projects).where(eq(projects.ownerId, userId));
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(or(eq(projects.ownerId, userId), inArray(projects.teamId, teamIds)));
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Project | undefined> {
|
||||
const rows = await db.select().from(projects).where(eq(projects.id, id));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async create(data: NewProject): Promise<Project> {
|
||||
const rows = await db.insert(projects).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewProject>): Promise<Project | undefined> {
|
||||
const rows = await db
|
||||
.update(projects)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(projects.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const rows = await db.delete(projects).where(eq(projects.id, id)).returning();
|
||||
return rows.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ProjectsRepo = ReturnType<typeof createProjectsRepo>;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { eq, type Db, tasks } from '@mosaicstack/db';
|
||||
|
||||
export type Task = typeof tasks.$inferSelect;
|
||||
export type NewTask = typeof tasks.$inferInsert;
|
||||
|
||||
export function createTasksRepo(db: Db) {
|
||||
return {
|
||||
async findAll(): Promise<Task[]> {
|
||||
return db.select().from(tasks);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Task | undefined> {
|
||||
const rows = await db.select().from(tasks).where(eq(tasks.id, id));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByProject(projectId: string): Promise<Task[]> {
|
||||
return db.select().from(tasks).where(eq(tasks.projectId, projectId));
|
||||
},
|
||||
|
||||
async findByMission(missionId: string): Promise<Task[]> {
|
||||
return db.select().from(tasks).where(eq(tasks.missionId, missionId));
|
||||
},
|
||||
|
||||
async findByStatus(status: Task['status']): Promise<Task[]> {
|
||||
return db.select().from(tasks).where(eq(tasks.status, status));
|
||||
},
|
||||
|
||||
async create(data: NewTask): Promise<Task> {
|
||||
const rows = await db.insert(tasks).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewTask>): Promise<Task | undefined> {
|
||||
const rows = await db
|
||||
.update(tasks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(tasks.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const rows = await db.delete(tasks).where(eq(tasks.id, id)).returning();
|
||||
return rows.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type TasksRepo = ReturnType<typeof createTasksRepo>;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user