test(backend): document cosmos query scope
This commit is contained in:
parent
6741e93d55
commit
205c44bc3f
150
backend/src/modules/repository-scope.test.ts
Normal file
150
backend/src/modules/repository-scope.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
40
docs/COSMOS_QUERY_REVIEW.md
Normal file
40
docs/COSMOS_QUERY_REVIEW.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user