d3ro-voice/apps/mobile-rn/src/features/knowledge/knowledge-realtime.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

110 lines
3.5 KiB
TypeScript

import { AppState } from 'react-native'
/**
* Realtime policy for the mobile Knowledge screen.
*
* Supabase Postgres Changes does not apply RLS to DELETE events and cannot
* filter them, so an unfiltered DELETE subscription would deliver every
* account's deleted primary keys to every subscriber and make each client
* refetch on every deletion anywhere (N-client fan-out). Only INSERT/UPDATE are
* subscribed (RLS-scoped, optionally narrowed to the owner), and deletions are
* reconciled by foreground polling, mirroring use-history-sync.ts.
*/
export type KnowledgeRealtimeEvent = 'INSERT' | 'UPDATE'
export interface KnowledgeRealtimeBinding {
event: KnowledgeRealtimeEvent
schema: 'public'
table: 'knowledge_documents'
filter?: string
}
export const KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS = 45_000
const SUBSCRIBED_EVENTS: readonly KnowledgeRealtimeEvent[] = ['INSERT', 'UPDATE']
/**
* Pure: the postgres_changes bindings the Knowledge screen may open. DELETE is
* intentionally never included. `ownerId` must already be validated by the
* caller because it is interpolated into the realtime filter.
*/
export function knowledgeRealtimeBindings(ownerId?: string): KnowledgeRealtimeBinding[] {
return SUBSCRIBED_EVENTS.map((event) => ({
event,
schema: 'public',
table: 'knowledge_documents',
...(ownerId === undefined ? {} : { filter: `user_id=eq.${ownerId}` }),
}))
}
/** Port: whether the app is in the foreground, and changes to that state. */
export interface ForegroundStatePort {
isActive: () => boolean
onChange: (listener: (active: boolean) => void) => { remove: () => void }
}
/** Port: interval scheduling, injectable for tests. */
export interface IntervalPort {
set: (callback: () => void, ms: number) => ReturnType<typeof setInterval>
clear: (handle: ReturnType<typeof setInterval>) => void
}
export interface ForegroundReconciliationDeps {
foreground?: ForegroundStatePort
interval?: IntervalPort
intervalMs?: number
}
export interface ForegroundReconciliation {
stop: () => void
}
export const reactNativeForegroundState: ForegroundStatePort = {
isActive: () => AppState.currentState === 'active',
onChange: (listener) => {
const subscription = AppState.addEventListener('change', (state) => {
listener(state === 'active')
})
return { remove: () => subscription.remove() }
},
}
const systemInterval: IntervalPort = {
set: (callback, ms) => setInterval(callback, ms),
clear: (handle) => clearInterval(handle),
}
/**
* Calls `onReconcile` periodically while the app is in the foreground and once
* whenever it returns to the foreground, so remote deletions are picked up
* without a DELETE realtime subscription.
*/
export function startForegroundReconciliation(
onReconcile: () => void,
deps: ForegroundReconciliationDeps = {},
): ForegroundReconciliation {
const foreground = deps.foreground ?? reactNativeForegroundState
const interval = deps.interval ?? systemInterval
const intervalMs = deps.intervalMs ?? KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS
let stopped = false
let active = foreground.isActive()
const stateSubscription = foreground.onChange((nextActive) => {
const resumed = nextActive && !active
active = nextActive
if (resumed && !stopped) onReconcile()
})
const handle = interval.set(() => {
if (active && !stopped) onReconcile()
}, intervalMs)
return {
stop: () => {
if (stopped) return
stopped = true
interval.clear(handle)
stateSubscription.remove()
},
}
}