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:
Till JS 2026-04-20 18:26:01 +02:00
parent dc1a0a65fb
commit 6d8637b837
5 changed files with 48 additions and 16 deletions

View file

@ -24,6 +24,7 @@ export { reconcileSentinels, personalSpaceSentinel } from './bootstrap';
export {
scopedTable,
scopedForModule,
scopedGet,
assertModuleAllowed,
getInScopeSpaceIds,
ScopeNotReadyError,

View file

@ -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;
}

View file

@ -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);

View file

@ -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;

View file

@ -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[]);
}