mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(spaces): migrate todo/notes/contacts to scoped-db + add scopedGet
Three more modules now use the scope wrapper. Pattern matches the
calendar pilot:
db.table<T>('X').toArray() → scopedForModule<T>('mod','X').toArray()
db.table<T>('X').orderBy('k').toArray → scopedForModule<T>(...).sortBy('k')
db.table<T>('X').get(id) → scopedGet<T>('X', id)
Added scopedGet() to the scope barrel — a primary-key fetch with a
post-read scope check so URL-manipulated deep links can't peek at
records from another space. Dexie's fast-path index read still happens;
the scope check is one field comparison on the single row.
Modules migrated:
- todo/queries.ts: useAllTasks, useAllBoardViews, useAllReminders,
useAllProjects (4 queries; sortBy replaces orderBy-via-index)
- notes/queries.ts: useAllNotes (list), useNote (by id via scopedGet)
- contacts/queries.ts: useAllContacts
goals module lives in companion/goals with a different layout (not a
standard modules/*/queries.ts) — skipped this pass, will migrate in a
targeted follow-up.
Scope + visibility filters run BEFORE decrypt where possible so the
vault-locked UI path stays cheap: plaintext spaceId + visibility + deletedAt
metadata filters the decrypt workload before crypto gets invoked.
Performance note: sortBy() is an in-memory O(n) sort. Fine for a user's
task list, but if a hot path surfaces (e.g. a thousands-of-tasks view),
we add a [spaceId+order] compound index in a follow-up Dexie version.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dc1a0a65fb
commit
6d8637b837
5 changed files with 48 additions and 16 deletions
|
|
@ -24,6 +24,7 @@ export { reconcileSentinels, personalSpaceSentinel } from './bootstrap';
|
|||
export {
|
||||
scopedTable,
|
||||
scopedForModule,
|
||||
scopedGet,
|
||||
assertModuleAllowed,
|
||||
getInScopeSpaceIds,
|
||||
ScopeNotReadyError,
|
||||
|
|
|
|||
|
|
@ -105,3 +105,23 @@ export function scopedForModule<T, PK>(
|
|||
assertModuleAllowed(moduleId);
|
||||
return scopedTable<T, PK>(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single record by primary key with a scope check. Returns undefined
|
||||
* if the record doesn't exist OR if its spaceId isn't in the current
|
||||
* in-scope set — i.e. the user manipulated a URL parameter and tried to
|
||||
* peek at a record from a space they don't have active.
|
||||
*
|
||||
* Uses the Dexie primary-key fast path under the hood; the scope check
|
||||
* is a single field comparison on the one row returned.
|
||||
*/
|
||||
export async function scopedGet<T>(tableName: string, id: string | number): Promise<T | undefined> {
|
||||
const record = (await db.table(tableName).get(id)) as T | undefined;
|
||||
if (!record) return undefined;
|
||||
const rec = record as { spaceId?: unknown };
|
||||
const ids = getInScopeSpaceIds();
|
||||
if (typeof rec.spaceId !== 'string' || !ids.includes(rec.spaceId)) {
|
||||
return undefined;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule, applyVisibility } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||
import { contactTagOps } from './stores/tags.svelte';
|
||||
|
|
@ -53,9 +54,8 @@ export function toContact(local: LocalContact): Contact {
|
|||
|
||||
export function useAllContacts() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (await db.table<LocalContact>('contacts').toArray()).filter(
|
||||
(c) => !c.deletedAt
|
||||
);
|
||||
const raw = await scopedForModule<LocalContact, string>('contacts', 'contacts').toArray();
|
||||
const visible = applyVisibility(raw).filter((c) => !c.deletedAt);
|
||||
const decrypted = await decryptRecords('contacts', visible);
|
||||
const tagMap = await contactTagOps.getTagIdsForMany(decrypted.map((c) => c.id));
|
||||
const scoped = filterBySceneScopeBatch(decrypted, (c) => c.id, tagMap);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule, scopedGet, applyVisibility } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||
import { noteTagOps } from './stores/tags.svelte';
|
||||
|
|
@ -44,10 +45,10 @@ export function useAllNotes() {
|
|||
// Filter on plaintext metadata first — none of these fields are
|
||||
// in the encryption registry, so they stay readable even with
|
||||
// the vault locked. Cuts the decrypt workload to only what the
|
||||
// view actually renders.
|
||||
const visible = (await db.table<LocalNote>('notes').toArray()).filter(
|
||||
(n) => !n.deletedAt && !n.isArchived
|
||||
);
|
||||
// view actually renders. Scope + visibility filters run before
|
||||
// decrypt for the same reason.
|
||||
const raw = await scopedForModule<LocalNote, string>('notes', 'notes').toArray();
|
||||
const visible = applyVisibility(raw).filter((n) => !n.deletedAt && !n.isArchived);
|
||||
// Locked vault returns the blobs untouched so the UI can render
|
||||
// a "🔒" placeholder where title/content would be.
|
||||
const decrypted = await decryptRecords('notes', visible);
|
||||
|
|
@ -64,7 +65,9 @@ export function useAllNotes() {
|
|||
export function useNote(id: string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const local = await db.table<LocalNote>('notes').get(id);
|
||||
// scopedGet returns undefined if the note belongs to another
|
||||
// space — protects against URL-manipulated deep links.
|
||||
const local = await scopedGet<LocalNote>('notes', id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('notes', [local]);
|
||||
return decrypted ? toNote(decrypted) : null;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule, applyVisibility } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||
import type {
|
||||
|
|
@ -44,8 +45,11 @@ export function toTask(local: LocalTask): Task {
|
|||
|
||||
export function useAllTasks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalTask>('tasks').orderBy('order').toArray();
|
||||
const visible = locals.filter((t) => !t.deletedAt);
|
||||
// Scope-first, then in-memory sort by `order`. sortBy is O(n) — fine
|
||||
// for a user's own task list; if it ever becomes hot, add a
|
||||
// [spaceId+order] compound index in a follow-up Dexie version.
|
||||
const locals = await scopedForModule<LocalTask, string>('todo', 'tasks').sortBy('order');
|
||||
const visible = applyVisibility(locals).filter((t) => !t.deletedAt);
|
||||
const decrypted = await decryptRecords('tasks', visible);
|
||||
// Batch tag lookup: 1 query instead of N
|
||||
const ids = decrypted.map((t) => t.id);
|
||||
|
|
@ -68,22 +72,26 @@ export { useAllTags as useAllLabels } from '@mana/shared-stores';
|
|||
|
||||
export function useAllBoardViews() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalBoardView>('boardViews').orderBy('order').toArray();
|
||||
return locals.filter((v) => !v.deletedAt);
|
||||
const locals = await scopedForModule<LocalBoardView, string>('todo', 'boardViews').sortBy(
|
||||
'order'
|
||||
);
|
||||
return applyVisibility(locals).filter((v) => !v.deletedAt);
|
||||
}, [] as LocalBoardView[]);
|
||||
}
|
||||
|
||||
export function useAllReminders() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalReminder>('reminders').toArray();
|
||||
return locals.filter((r) => !r.deletedAt);
|
||||
const locals = await scopedForModule<LocalReminder, string>('todo', 'reminders').toArray();
|
||||
return applyVisibility(locals).filter((r) => !r.deletedAt);
|
||||
}, [] as LocalReminder[]);
|
||||
}
|
||||
|
||||
export function useAllProjects() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalTodoProject>('todoProjects').orderBy('order').toArray();
|
||||
return locals.filter((p) => !p.deletedAt);
|
||||
const locals = await scopedForModule<LocalTodoProject, string>('todo', 'todoProjects').sortBy(
|
||||
'order'
|
||||
);
|
||||
return applyVisibility(locals).filter((p) => !p.deletedAt);
|
||||
}, [] as LocalTodoProject[]);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue