Files
buzz/desktop/src/shared/api/tauriArchive.ts
T

350 lines
12 KiB
TypeScript

import { invokeTauri } from "./tauri";
// ── Wire-shape types (raw Tauri responses) ───────────────────────────────────
/**
* `list_save_subscriptions` returns rows directly from SQLite.
* The `kinds` column is stored as a JSON text string (e.g. `"[9,40002]"`),
* NOT a number array — it must be decoded before use.
*/
type RawSaveSubscription = {
identity_pubkey: string;
relay_url: string;
scope_type: string;
scope_value: string;
/** JSON-encoded integer array, e.g. `"[9,40002]"`. */
kinds: string;
created_at: number;
};
// ── Public types ─────────────────────────────────────────────────────────────
export type ScopeType = "channel_h" | "owner_p" | "referenced_e";
export type SaveSubscription = {
identityPubkey: string;
relayUrl: string;
scopeType: ScopeType;
scopeValue: string;
kinds: number[];
createdAt: number;
};
export type ArchiveBatchResult = {
persisted: number;
dropped: number;
};
// ── Subscription-change notifier ─────────────────────────────────────────────
/**
* Module-level notifier for subscription mutations (create/delete).
* The archive sync manager subscribes to this to reload live subscriptions
* without needing manager instances threaded through UI props.
*/
const subscriptionChangeListeners = new Set<() => void>();
export function onSubscriptionChange(listener: () => void): () => void {
subscriptionChangeListeners.add(listener);
return () => subscriptionChangeListeners.delete(listener);
}
function notifySubscriptionChange(): void {
for (const listener of subscriptionChangeListeners) {
listener();
}
}
// ── Decoder ──────────────────────────────────────────────────────────────────
function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
let kinds: number[] = [];
try {
const parsed = JSON.parse(raw.kinds);
if (
Array.isArray(parsed) &&
parsed.every((k) => typeof k === "number" && Number.isFinite(k))
) {
kinds = parsed as number[];
} else {
console.warn(
"[tauriArchive] malformed kinds JSON (not number[]):",
raw.kinds,
);
}
} catch {
console.warn("[tauriArchive] failed to parse kinds JSON:", raw.kinds);
}
return {
identityPubkey: raw.identity_pubkey,
relayUrl: raw.relay_url,
scopeType: raw.scope_type as ScopeType,
scopeValue: raw.scope_value,
kinds,
createdAt: raw.created_at,
};
}
// ── API wrappers ─────────────────────────────────────────────────────────────
/**
* Returns `true` when observer-feed archive policy is enforced.
*
* Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time;
* OSS builds never set it, so this returns `false`. The frontend calls this
* every startup to decide whether to reconcile the `owner_p` subscription.
*/
export async function observerArchiveDefaultEnabled(): Promise<boolean> {
return invokeTauri<boolean>("observer_archive_default_enabled");
}
/**
* Returns `true` when the build has agent-turn-metric archive default-on.
*
* Internal builds set `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` at build time;
* OSS builds never set it, so this returns `false`. The frontend calls this
* once at startup to decide whether to auto-seed an `owner_p` [44200]
* subscription.
*/
export async function agentMetricArchiveDefaultEnabled(): Promise<boolean> {
return invokeTauri<boolean>("agent_metric_archive_default_enabled");
}
/**
* Atomically merge `kind` into the `owner_p` save subscription for the
* current identity + relay.
*
* Performs a read-modify-write inside a single SQLite transaction on the Rust
* side, so concurrent callers (e.g. observer seed + metric seed racing on
* first run) cannot clobber each other's kind.
*
* Called by both `useObserverArchiveSeed` and `useAgentMetricArchiveSeed`
* instead of the former list → merge-in-TS → create pattern.
*/
export async function mergeSaveSubscriptionKinds(kind: number): Promise<void> {
await invokeTauri("merge_save_subscription_kinds", { kind });
notifySubscriptionChange();
}
/**
* Atomically remove `kind` from the `owner_p` save subscription for the
* current identity + relay.
*
* Mirrors `mergeSaveSubscriptionKinds`: reads existing kinds, removes `kind`,
* then deletes the row if the list becomes empty or updates it otherwise.
* Uses `BEGIN IMMEDIATE` on the Rust side for the same reason as the merge
* path — concurrent toggle-OFF callers serialize rather than racing.
*
* Called by toggle-OFF handlers in `LocalArchiveSettingsCard` for both
* kind 24200 and kind 44200, replacing the former TS-side read-modify-overwrite
* that would drop the *other* kind if `subs` state was stale.
*/
export async function removeSaveSubscriptionKind(kind: number): Promise<void> {
await invokeTauri("remove_save_subscription_kind", { kind });
notifySubscriptionChange();
}
/**
* Create a save subscription.
* Runs an access probe on the backend (channel membership, event readability).
* `kinds` is sent as a plain number array — Tauri serializes it correctly.
*/
export async function createSaveSubscription(
scopeType: ScopeType,
scopeValue: string,
kinds: number[],
): Promise<void> {
await invokeTauri("create_save_subscription", {
scopeType,
scopeValue,
kinds,
});
notifySubscriptionChange();
}
/**
* List all save subscriptions for the current identity + relay.
* Decodes the raw `kinds` string column into `number[]`.
*/
export async function listSaveSubscriptions(): Promise<SaveSubscription[]> {
const rows = await invokeTauri<RawSaveSubscription[]>(
"list_save_subscriptions",
);
return rows.map(decodeRawSubscription);
}
/**
* Delete a save subscription.
* Returns `true` if a row was removed, `false` if it didn't exist.
*/
export async function deleteSaveSubscription(
scopeType: ScopeType,
scopeValue: string,
): Promise<boolean> {
const removed = await invokeTauri<boolean>("delete_save_subscription", {
scopeType,
scopeValue,
});
if (removed) {
notifySubscriptionChange();
}
return removed;
}
/**
* Archive a batch of event candidates.
*
* Wire-shape note (verified against Rust source at `archive/mod.rs`):
* - `ArchiveCandidate` has no `#[serde(rename_all)]`, so struct field names
* are verbatim: `raw_event_json`, `matched_scope`.
* - `MatchedScope` field names are also verbatim: `scope_type`, `scope_value`.
* - `ScopeType` enum has `#[serde(rename_all = "snake_case")]`: values are
* `"channel_h"`, `"owner_p"`, `"referenced_e"`.
* - Tauri 2 only camelCases top-level command arg names, NOT nested struct
* fields — so `candidates` is passed as-is, with snake_case field names.
*/
export async function archiveEvents(
candidates: Array<{
rawEventJson: string;
matchedScope: { scopeType: ScopeType; scopeValue: string };
}>,
): Promise<ArchiveBatchResult> {
return invokeTauri<ArchiveBatchResult>("archive_events", {
candidates: candidates.map((c) => ({
raw_event_json: c.rawEventJson,
matched_scope: {
scope_type: c.matchedScope.scopeType,
scope_value: c.matchedScope.scopeValue,
},
})),
});
}
/**
* Read a paginated page of archived kind 24200 (observer) events for a
* specific channel, using the `observer_channel_index`.
*
* Only returns frames whose `channelId` was successfully decrypted and
* matched this channel. Frames with null/decrypt-failed channelId are
* excluded (Will's (a) ruling). Compound cursor + short-page exhaustion
* signal work identically to `readArchivedEvents`.
*/
export async function readArchivedObserverEventsForChannel(
channelId: string,
opts?: {
before?: { createdAt: number; id: string } | null;
limit?: number;
},
): Promise<import("@/shared/api/types").RelayEvent[]> {
const rawRows = await invokeTauri<string[]>(
"read_archived_observer_events_for_channel",
{
channelId,
beforeCreatedAt: opts?.before?.createdAt ?? null,
beforeId: opts?.before?.id ?? null,
limit: opts?.limit ?? null,
},
);
return rawRows
.map((raw) => {
try {
return JSON.parse(raw) as import("@/shared/api/types").RelayEvent;
} catch {
console.warn(
"[tauriArchive] failed to parse archived observer raw_json:",
raw,
);
return null;
}
})
.filter((e): e is import("@/shared/api/types").RelayEvent => e !== null);
}
/**
* Index one or more archived observer frames by channelId.
*
* `channelId` is nullable: pass `null` for frames that are unscoped,
* malformed, or whose payload could not be decrypted. Null rows are written
* as a processed-state marker so re-runs skip them; they are never returned
* by channel-scoped reads (which filter `channel_id = ?`).
*
* Idempotent — already-indexed frames are silently skipped.
*/
export async function indexObserverChannelId(
entries: Array<{
eventId: string;
channelId: string | null;
createdAt: number;
}>,
): Promise<void> {
if (entries.length === 0) return;
await invokeTauri("index_observer_channel_id", {
entries: entries.map((e) => ({
event_id: e.eventId,
channel_id: e.channelId,
created_at: e.createdAt,
})),
});
}
/**
* Return all `owner_p` kind 24200 archived event rows not yet indexed.
*
* Used by the one-shot backfill driver. Returns raw Nostr event JSON plus
* event id and created_at for each row so the caller can decrypt and index.
*/
export async function readUnindexedObserverRows(): Promise<
Array<{ id: string; rawJson: string; createdAt: number }>
> {
const rows = await invokeTauri<
Array<{ id: string; raw_json: string; created_at: number }>
>("read_unindexed_observer_rows");
return rows.map((r) => ({
id: r.id,
rawJson: r.raw_json,
createdAt: r.created_at,
}));
}
/**
* Read a paginated page of archived raw events for a scope.
*
* Returns at most `limit` raw Nostr events (default 50) in newest-first order.
* Use `before` — a compound cursor `{ createdAt, id }` taken from the **last**
* (oldest) row of the previous page — to load the next older page. The
* predicate on the Rust side mirrors `ORDER BY created_at DESC, id DESC`
* exactly, so same-second siblings are never skipped at a page boundary.
* A page shorter than `limit` signals the archive is exhausted.
*
* Pass `kinds: null` (or omit) to admit all archived kinds. An empty array
* `[]` matches nothing — callers that want all events must omit/null `kinds`.
*/
export async function readArchivedEvents(
scopeType: ScopeType,
scopeValue: string,
opts?: {
kinds?: number[] | null;
before?: { createdAt: number; id: string } | null;
limit?: number;
},
): Promise<import("@/shared/api/types").RelayEvent[]> {
const rawRows = await invokeTauri<string[]>("read_archived_events", {
scopeType,
scopeValue,
kinds: opts?.kinds ?? null,
beforeCreatedAt: opts?.before?.createdAt ?? null,
beforeId: opts?.before?.id ?? null,
limit: opts?.limit ?? null,
});
return rawRows
.map((raw) => {
try {
return JSON.parse(raw) as import("@/shared/api/types").RelayEvent;
} catch {
console.warn("[tauriArchive] failed to parse archived raw_json:", raw);
return null;
}
})
.filter((e): e is import("@/shared/api/types").RelayEvent => e !== null);
}