mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
350 lines
12 KiB
TypeScript
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);
|
|
}
|