test(backend): document cosmos query scope

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:17:27 -07:00
parent 6741e93d55
commit 205c44bc3f
2 changed files with 190 additions and 0 deletions

View File

@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { resetMemoryDatastore } from '../test-helpers.js';
import { createNote, countNotesByWorkspaces, listNotes } from './notes/repository.js';
import { createNoteAgentAction, listNoteAgentActions, listPendingActions } from './note-agent-actions/repository.js';
import { createNoteShare, findShareByToken, listSharesForNote } from './note-shares/repository.js';
import { createCollaborator, listCollaboratorsForNote, listSharedWithMe } from './note-collaborators/repository.js';
import type { NoteDoc } from './notes/types.js';
import type { NoteAgentActionDoc } from './note-agent-actions/types.js';
import type { NoteShareDoc } from './note-shares/types.js';
import type { NoteCollaboratorDoc } from './note-collaborators/types.js';
const now = '2026-05-05T00:00:00.000Z';
beforeEach(() => {
resetMemoryDatastore();
});
function note(overrides: Partial<NoteDoc>): NoteDoc {
return {
id: 'note-1',
productId: 'notelett',
workspaceId: 'ws-1',
userId: 'user-1',
title: 'Scoped note',
body: 'body',
status: 'active',
tags: [],
links: [],
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
...overrides,
};
}
function action(overrides: Partial<NoteAgentActionDoc>): NoteAgentActionDoc {
return {
id: 'action-1',
productId: 'notelett',
workspaceId: 'ws-1',
userId: 'user-1',
noteId: 'note-1',
actorId: 'agent-1',
actorType: 'agent',
toolName: 'notes.notes.update',
actionType: 'update',
state: 'draft',
createdAt: now,
updatedAt: now,
createdBy: 'agent-1',
updatedBy: 'agent-1',
...overrides,
};
}
function share(overrides: Partial<NoteShareDoc>): NoteShareDoc {
return {
id: 'share-1',
productId: 'notelett',
workspaceId: 'ws-1',
userId: 'user-1',
noteId: 'note-1',
shareToken: 'token-1',
createdAt: now,
...overrides,
};
}
function collaborator(overrides: Partial<NoteCollaboratorDoc>): NoteCollaboratorDoc {
return {
id: 'collab-1',
productId: 'notelett',
noteId: 'note-1',
workspaceId: 'ws-1',
sharedByUserId: 'user-1',
sharedWithUserId: 'user-2',
permission: 'view',
createdAt: now,
...overrides,
};
}
describe('repository scope isolation', () => {
it('keeps note list and workspace counts scoped by user, product, and workspace', async () => {
await createNote(note({ id: 'note-visible' }));
await createNote(note({ id: 'note-other-user', userId: 'user-2' }));
await createNote(note({ id: 'note-other-product', productId: 'other-product' }));
await createNote(note({ id: 'note-other-workspace', workspaceId: 'ws-2' }));
const scoped = await listNotes('user-1', 'notelett', {
workspaceId: 'ws-1',
limit: 50,
offset: 0,
});
expect(scoped.items.map(item => item.id)).toEqual(['note-visible']);
expect(scoped.total).toBe(1);
const allUserNotes = await listNotes('user-1', 'notelett', { limit: 50, offset: 0 });
expect(allUserNotes.items.map(item => item.id).sort()).toEqual(['note-other-workspace', 'note-visible']);
const counts = await countNotesByWorkspaces('user-1', 'notelett', ['ws-1', 'ws-2']);
expect([...counts.entries()]).toEqual([
['ws-1', 1],
['ws-2', 1],
]);
});
it('keeps agent action lists scoped by user, product, and workspace', async () => {
await createNoteAgentAction(action({ id: 'action-visible' }));
await createNoteAgentAction(action({ id: 'action-other-user', userId: 'user-2' }));
await createNoteAgentAction(action({ id: 'action-other-product', productId: 'other-product' }));
await createNoteAgentAction(action({ id: 'action-other-workspace', workspaceId: 'ws-2' }));
const workspaceActions = await listNoteAgentActions('user-1', 'notelett', {
workspaceId: 'ws-1',
limit: 50,
offset: 0,
});
expect(workspaceActions.items.map(item => item.id)).toEqual(['action-visible']);
expect(workspaceActions.total).toBe(1);
const pending = await listPendingActions('user-1', 'notelett');
expect(pending.items.map(item => item.id).sort()).toEqual(['action-other-workspace', 'action-visible']);
expect(pending.total).toBe(2);
});
it('keeps share and collaborator lookups scoped by product, user, note, and workspace filters', async () => {
await createNoteShare(share({ id: 'share-other-product', productId: 'other-product', shareToken: 'foreign-token' }));
expect(await findShareByToken('foreign-token', 'notelett')).toBeNull();
await createNoteShare(share({ id: 'share-visible', shareToken: 'token-visible' }));
await createNoteShare(share({ id: 'share-other-user', userId: 'user-2' }));
await createNoteShare(share({ id: 'share-other-note', noteId: 'note-2' }));
const shares = await listSharesForNote('user-1', 'notelett', 'ws-1', 'note-1');
expect(shares.map(item => item.id)).toEqual(['share-visible']);
expect((await findShareByToken('token-visible', 'notelett'))?.id).toBe('share-visible');
await createCollaborator(collaborator({ id: 'collab-visible' }));
await createCollaborator(collaborator({ id: 'collab-other-product', productId: 'other-product' }));
await createCollaborator(collaborator({ id: 'collab-other-note', noteId: 'note-2' }));
await createCollaborator(collaborator({ id: 'collab-other-recipient', sharedWithUserId: 'user-3' }));
const noteCollaborators = await listCollaboratorsForNote('note-1', 'notelett');
expect(noteCollaborators.map(item => item.id)).toEqual(['collab-visible', 'collab-other-recipient']);
const sharedWithMe = await listSharedWithMe('user-2', 'notelett');
expect(sharedWithMe.map(item => item.id)).toEqual(['collab-visible', 'collab-other-note']);
});
});

View File

@ -0,0 +1,40 @@
# Cosmos Query Review
Date: 2026-05-05
This review covers current NoteLett repository access patterns against the Cosmos container registrations in `backend/src/lib/cosmos-init.ts`. All documents retain `productId: "notelett"` and all list routes include product/user/workspace scope filters before returning user data.
## Container Partitions
| Container | Partition key | Primary access pattern |
| --- | --- | --- |
| `notes`, `note_relationships`, `note_tasks`, `note_artifacts`, `note_agent_actions`, `note_shares`, `note_versions` | `/workspaceId` | Workspace-scoped note surfaces and agent write audit trail |
| `workspaces`, `saved_views`, `note_prompts`, `note_prompt_schedules`, `note_prompt_webhooks`, `note_intake_rules`, `note_intake_jobs` | `/userId` | User-owned configuration, templates, and intake state |
| `note_collaborators` | `/sharedWithUserId` | Shared-with-me lookup; owner-side collaborator listing is note-scoped |
| `palace_*` | `/userId` | Per-user memory palace data and maintenance operations |
## Cross-Partition Or Count-Heavy Operations
| Operation | Why it can cross partitions | Guardrails |
| --- | --- | --- |
| `notes.listNotes()` without `workspaceId` | `notes` is partitioned by `/workspaceId`, but dashboard/search surfaces can list across a user's workspaces. | Always filters by `userId` and `productId`; paginated to caller limit. Workspace-specific calls include `workspaceId`. |
| `note_agent_actions.listPendingActions()` | Pending review queue intentionally spans workspaces in the `/workspaceId` container. | Filters by `userId`, `productId`, and state; performs two count queries plus two bounded reads. |
| `note_shares.findShareByToken()` | Public share-token resolution starts from token/product instead of workspace. | Filters by `shareToken` and `productId`, returns one record, and rejects expired shares before public note lookup revalidates note/user/product scope. |
| `note_collaborators.listCollaboratorsForNote()` | The container is optimized for `/sharedWithUserId`; owner-side note collaborator listing starts from `noteId`. | Filters by `noteId` and `productId`, limits to 100; route verifies the caller can access the note before listing. |
| `palace` search/maintenance scans | Palace data is `/userId` partitioned, but some relevance, duplicate, KG, and maintenance jobs scan hundreds to thousands of user memories/triples. | All scans include `userId` and `productId`; limits are explicit (`500`, `1000`, or `5000`) and remain single-user partition reads. |
| `palace.getPalaceStats()` | Counts six Palace containers for a user. | Counts are all scoped by `userId` and `productId`; suitable for diagnostics/dashboard use. |
| Prompt template builtin merge | User templates and builtins live in separate `/userId` partitions. | User partition and `__builtin__` partition are queried separately, then merged in memory with limit bounds. |
## Regression Coverage
`backend/src/modules/repository-scope.test.ts` verifies scope isolation for:
- Notes list and workspace counts across user, product, and workspace boundaries.
- Agent action workspace lists and cross-workspace pending queues across user and product boundaries.
- Share token, share list, note collaborator, and shared-with-me lookups across product, user, note, and workspace filters.
## Follow-Up Notes
- If dashboard global note search becomes high traffic, prefer a user-partitioned materialized search/index container or a dedicated search service over broad `/workspaceId` fan-out.
- If pending review volume grows, consider a user-partitioned projection for `draft`/`proposed` agent actions to avoid two cross-workspace counts per queue load.
- Public share tokens should remain high-entropy and globally unique; token lookup is intentionally cross-partition but bounded to one item.