+111
@@ -0,0 +1,111 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Live collections on the Convex profile (umbrella TS6, the Convex mapping of `@conusai-kit/ui/data`).
|
||||
// The browser keeps the facade it has on every profile -- `liveCollection`, `useLive`, `useWrite` over the
|
||||
// app's CLP/1 stream, `POST /api/v1/orgs/{org}/live` -- and the web process serves that stream from these
|
||||
// functions (apps/web/src/server/convex-live.ts):
|
||||
//
|
||||
// open who may stream what, decided HERE, over this deployment's rows, with the caller's verified
|
||||
// identity (the BFF attaches it with the admin key) and the kernel's tenancy: the same rule as
|
||||
// the Postgres profile's (`decideLiveGrants`). Another organization, a workspace the caller does
|
||||
// not resolve, or no identity is refused; a collection the caller may not read is denied.
|
||||
// The BFF answers a refusal as the Postgres profile's live problem (401, 404).
|
||||
// recheck each heartbeat's question, under the caller's identity: null while the caller still reaches
|
||||
// the organization and every topic and still reads every granted collection, else why the
|
||||
// stream ends (`grants_changed` when a read permission was lost)
|
||||
// head the organization's log head; the web subscribes to it as a Convex reactive query, so a
|
||||
// committed write wakes the organization's streams at once (internal: admin key only)
|
||||
// changes the organization's changes after a cursor, with the head of the same snapshot (internal)
|
||||
// prune the hourly cron: changes older than 24 hours go, as on the Postgres profile
|
||||
//
|
||||
// The log holds ids only; a browser reads each changed row back through the collection's batch-get
|
||||
// procedure, a generated tenant query under its own permission and the repository's tenant predicate.
|
||||
|
||||
import { AppError, toProblem } from "@conusai-kit/core"
|
||||
import { LIVE_RETENTION_MS, liveLogHead, pruneLiveLog, readLiveLog } from "@conusai-kit/data-convex"
|
||||
import { decideLiveGrants, recheckLiveScopes } from "@conusai-kit/live-server/grants"
|
||||
import { ConvexError, type Value, v } from "convex/values"
|
||||
import { internalMutation, internalQuery, query } from "./_generated/server"
|
||||
import { callContextOf, registry, tenancyOver } from "./lib/runtime"
|
||||
|
||||
const topic = v.object({ ws: v.string(), project: v.optional(v.string()) })
|
||||
|
||||
/** A refusal as the BFF reads it back: the live problem's own fields, or the kernel's problem. */
|
||||
function refusal(error: unknown): ConvexError<Value> {
|
||||
if (error instanceof AppError) return new ConvexError(toProblem(error) as unknown as Value)
|
||||
const problem = error as { status?: unknown; type?: unknown; title?: unknown; name?: unknown } | null
|
||||
if (problem?.name === "LiveProblem" && typeof problem.status === "number") {
|
||||
return new ConvexError({
|
||||
type: String(problem.type),
|
||||
title: String(problem.title),
|
||||
status: problem.status,
|
||||
code: problem.status === 401 ? "unauthenticated" : problem.status === 404 ? "not_found" : "live",
|
||||
})
|
||||
}
|
||||
return new ConvexError(toProblem(error) as unknown as Value)
|
||||
}
|
||||
|
||||
/** The grants of one stream for the calling identity, or its refusal (401 signed out, 404 not reached). */
|
||||
export const open = query({
|
||||
args: { org: v.string(), topics: v.array(topic), entities: v.array(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
const call = await callContextOf(ctx.auth)
|
||||
const grants = await decideLiveGrants({
|
||||
tenancy: tenancyOver(ctx.db),
|
||||
collections: registry.collections,
|
||||
org: args.org,
|
||||
actor: call.actor,
|
||||
...(call.pinnedOrgId ? { pinnedOrgId: call.pinnedOrgId } : {}),
|
||||
topics: args.topics,
|
||||
entities: args.entities,
|
||||
})
|
||||
return {
|
||||
org: grants.org,
|
||||
topics: grants.topics.map((t) => ({ ...t })),
|
||||
entities: [...grants.entities],
|
||||
denied: grants.denied.map((d) => ({ ...d })),
|
||||
}
|
||||
} catch (error) {
|
||||
throw refusal(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* The heartbeat's re-check: null while the caller still reaches the organization and every topic and holds
|
||||
* the read permission of every entity the stream was granted (`granted`), else why the stream ends.
|
||||
*/
|
||||
export const recheck = query({
|
||||
args: { org: v.string(), topics: v.array(topic), granted: v.array(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
const call = await callContextOf(ctx.auth)
|
||||
return await recheckLiveScopes({
|
||||
tenancy: tenancyOver(ctx.db),
|
||||
collections: registry.collections,
|
||||
org: args.org,
|
||||
actor: call.actor,
|
||||
...(call.pinnedOrgId ? { pinnedOrgId: call.pinnedOrgId } : {}),
|
||||
topics: args.topics,
|
||||
granted: args.granted,
|
||||
})
|
||||
} catch (error) {
|
||||
throw refusal(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const head = internalQuery({
|
||||
args: { orgId: v.string() },
|
||||
handler: async (ctx, { orgId }) => liveLogHead(ctx.db as never, orgId),
|
||||
})
|
||||
|
||||
export const changes = internalQuery({
|
||||
args: { orgId: v.string(), after: v.object({ txid: v.number(), seq: v.number() }), limit: v.number() },
|
||||
handler: async (ctx, args) => readLiveLog(ctx.db as never, args),
|
||||
})
|
||||
|
||||
export const prune = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => pruneLiveLog(ctx.db as never, { before: Date.now() - LIVE_RETENTION_MS, limit: 4000 }),
|
||||
})
|
||||
Reference in New Issue
Block a user