import type { ExpressionBuilder, Selectable } from 'kysely'
import { sql } from 'kysely'
import * as Scope from '../../Scope.js'
import type * as Webhooks from '../../internal/Webhooks.js'
import type * as Db from '../Db.js'
import type * as db_Schema from '../Schema.js'
const subscriptionLimitLockClass = 1464353612
/** Columns of the `webhook_subscriptions` table, derived from `Schema.WebhookSubscription`. */
export type Table = db_Schema.WebhookSubscription
/** A stored webhook-subscription row. */
export type Record = Selectable
/** Reads one live subscription by id for an internal Queue delivery. */
export async function getById(
db: Db.Db,
id: string,
now: string,
): Promise {
const row = await db.kysely
.selectFrom('webhook_subscriptions')
.selectAll()
.where('id', '=', id)
.where(live(now))
.executeTakeFirst()
return row ? toSubscription(row) : null
}
/** Maps an owner union to its stored `ownerId` column value. */
export function ownerId(owner: Webhooks.Owner): string {
return owner.type === 'api_key' ? owner.orgId : owner.payer
}
/** Maps a stored row to the domain subscription (null columns → absent fields). */
export function toSubscription(row: Record): Webhooks.Subscription {
return {
chainId: row.chainId,
...(row.context === null ? {} : { context: row.context }),
createdAt: row.createdAt,
destination: row.destination,
...(row.environment === null ? {} : { environment: row.environment }),
eventType: row.eventType,
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
failureCount: row.failureCount,
filters: row.filters,
id: row.id,
...(row.lastDeliveryAt === null ? {} : { lastDeliveryAt: row.lastDeliveryAt }),
owner:
row.ownerType === 'api_key'
? { orgId: row.ownerId, type: 'api_key' }
: { payer: row.ownerId, type: 'mpp' },
...(row.projectId === null ? {} : { projectId: row.projectId }),
secret: row.secret,
status: row.status,
updatedAt: row.updatedAt,
}
}
/**
* Inserts a subscription row from its domain value.
*
* @param db - The database.
* @param subscription - The subscription to insert.
* @param options - Initial cursor options.
*/
export async function insert(
db: Db.Db,
subscription: Webhooks.Subscription,
options: insert.Options = {},
): Promise {
await db.kysely
.insertInto('webhook_subscriptions')
.values({
chainId: subscription.chainId,
context: subscription.context ?? null,
createdAt: subscription.createdAt,
destination: subscription.destination,
environment: subscription.environment ?? null,
eventType: subscription.eventType,
expiresAt: subscription.expiresAt ?? null,
failureCount: subscription.failureCount,
filters: subscription.filters,
id: subscription.id,
lastDeliveryAt: subscription.lastDeliveryAt ?? null,
ownerId: ownerId(subscription.owner),
ownerType: subscription.owner.type,
pollerCursor: options.pollerCursor ?? null,
projectId: subscription.projectId ?? null,
secret: subscription.secret,
status: subscription.status,
updatedAt: subscription.updatedAt,
})
.execute()
}
export declare namespace insert {
/** Options for {@link insert}. */
type Options = {
/** Initial cursor committed with the subscription. */
pollerCursor?: string | undefined
}
}
/**
* Inserts a subscription when its owner remains below the configured cap.
* An owner-scoped transaction lock serializes the count and insert.
*/
export async function insertWithinLimit(
db: Db.Db,
subscription: Webhooks.Subscription,
options: insertWithinLimit.Options,
): Promise {
return db.transaction(async (tx) => {
const owner = `${subscription.owner.type}:${ownerId(subscription.owner)}`
await sql`select pg_advisory_xact_lock(${subscriptionLimitLockClass}, hashtext(${owner}))`.execute(tx.kysely) // prettier-ignore
const existing = await count(tx, subscription.owner, options.now)
if (existing >= options.maxPerOwner) return false
await insert(
tx,
subscription,
options.pollerCursor === undefined ? {} : { pollerCursor: options.pollerCursor },
)
return true
})
}
export declare namespace insertWithinLimit {
/** Options for an atomic capped insert. */
type Options = {
/** Maximum live subscriptions the owner may hold. */
maxPerOwner: number
/** ISO timestamp used to exclude expired rows from the count. */
now: string
/** Initial cursor committed with the subscription. */
pollerCursor?: string | undefined
}
}
/**
* Reads an owner's subscription by id; expired rows never match (query-time
* expiry, same read semantics as the old store TTL).
*
* @param db - The database.
* @param owner - The authenticated owner.
* @param id - The subscription id (`wh_…`).
* @param now - ISO timestamp expiry is evaluated against.
* @returns The subscription, or `null` when absent (or not the owner's).
*/
export async function get(
db: Db.Db,
owner: Webhooks.Owner,
id: string,
now: string,
access?: Webhooks.Access,
): Promise {
let query = db.kysely
.selectFrom('webhook_subscriptions')
.selectAll()
.where('id', '=', id)
.where('ownerType', '=', owner.type)
.where('ownerId', '=', ownerId(owner))
.where(live(now))
if (access) query = query.where(privateResourceAccess(access))
const row = await query.executeTakeFirst()
return row ? toSubscription(row) : null
}
/**
* Lists an owner's live subscriptions, newest first, with keyset paging
* (`id < cursor` — ids embed a zero-padded timestamp, so lexical order is
* chronological).
*
* @param db - The database.
* @param owner - The authenticated owner.
* @param options - Paging options plus the expiry timestamp.
* @returns The subscriptions.
*/
export async function list(
db: Db.Db,
owner: Webhooks.Owner,
options: list.Options,
): Promise {
let query = db.kysely
.selectFrom('webhook_subscriptions')
.selectAll()
.where('ownerType', '=', owner.type)
.where('ownerId', '=', ownerId(owner))
.where(live(options.now))
.orderBy('id', 'desc')
if (options.access) query = query.where(privateResourceAccess(options.access))
if (options.cursor) query = query.where('id', '<', options.cursor)
if (options.offset) query = query.offset(options.offset)
if (options.limit !== undefined) query = query.limit(options.limit)
return (await query.execute()).map(toSubscription)
}
export declare namespace list {
/** Options for {@link list}. */
type Options = {
/** Private-resource visibility scope. */
access?: Webhooks.Access | undefined
/** Return subscriptions older than this id (keyset paging, newest first). */
cursor?: string | undefined
/** Maximum subscriptions to return. */
limit?: number | undefined
/** ISO timestamp expiry is evaluated against. */
now: string
/** Rows to skip from the head (positional pagination; exclusive with `cursor`). */
offset?: number | undefined
}
}
/**
* Counts an owner's live subscriptions.
*
* @param db - The database.
* @param owner - The authenticated owner.
* @param now - ISO timestamp expiry is evaluated against.
* @returns The count.
*/
export async function count(
db: Db.Db,
owner: Webhooks.Owner,
now: string,
access?: Webhooks.Access,
): Promise {
let query = db.kysely
.selectFrom('webhook_subscriptions')
.select((eb) => eb.fn.countAll().as('count'))
.where('ownerType', '=', owner.type)
.where('ownerId', '=', ownerId(owner))
.where(live(now))
if (access) query = query.where(privateResourceAccess(access))
const row = await query.executeTakeFirstOrThrow()
return Number(row.count)
}
/**
* Applies a column patch to a subscription. Only the provided columns are
* written; `context: null` clears the stored context.
*
* @param db - The database.
* @param id - The subscription id (`wh_…`).
* @param set - The columns to write.
* @returns The updated row's subscription, or `undefined` when absent.
*/
export async function update(
db: Db.Db,
id: string,
set: update.Set,
): Promise {
const row = await db.kysely
.updateTable('webhook_subscriptions')
.set(set)
.where('id', '=', id)
.returningAll()
.executeTakeFirst()
return row ? toSubscription(row) : undefined
}
export declare namespace update {
/** Columns writable by a subscription patch. */
type Set = {
/** New human context; null clears it. */
context?: Webhooks.Context | null | undefined
/** New delivery destination. */
destination?: Webhooks.Destination | undefined
/** New filter predicates. */
filters?: Webhooks.Subscription['filters'] | undefined
/** New lifecycle status. */
status?: Webhooks.Status | undefined
/** Mutation timestamp (ISO). */
updatedAt: string
}
}
/**
* Deletes an owner's live subscription; delivery rows cascade via the FK.
*
* @param db - The database.
* @param owner - The authenticated owner.
* @param id - The subscription id (`wh_…`).
* @param now - ISO timestamp expiry is evaluated against.
* @returns Whether a row was deleted.
*/
export async function remove(
db: Db.Db,
owner: Webhooks.Owner,
id: string,
now: string,
access?: Webhooks.Access,
): Promise {
let query = db.kysely
.deleteFrom('webhook_subscriptions')
.where('id', '=', id)
.where('ownerType', '=', owner.type)
.where('ownerId', '=', ownerId(owner))
.where(live(now))
if (access) query = query.where(privateResourceAccess(access))
const result = await query.executeTakeFirst()
return result.numDeletedRows > 0n
}
function privateResourceAccess(access: Webhooks.Access) {
const fundingRead = access.scopes.some(
(scope) => scope === Scope.wildcard || scope === 'funding:read',
)
return (eb: ExpressionBuilder) =>
eb.or([
sql`${eb.ref('eventType')} not like ${'funding:%'}`,
...(fundingRead
? [
eb.and([
sql`${eb.ref('eventType')} like ${'funding:%'}`,
eb('environment', '=', access.environment),
...(access.projectId === undefined ? [] : [eb('projectId', '=', access.projectId)]),
]),
]
: []),
])
}
/**
* Lists live `active` subscriptions for a `(chainId, eventType)` stream,
* served by the partial status index. Each row carries its cursor so callers
* avoid a per-subscription read.
*
* @param db - The database.
* @param chainId - Chain the stream belongs to.
* @param eventType - Event type of the stream.
* @param now - ISO timestamp expiry is evaluated against.
* @returns Cursor-and-subscription pairs, in id (chronological) order.
*/
export async function listActive(
db: Db.Db,
chainId: number,
eventType: Webhooks.EventType,
now: string,
): Promise<{ cursor: string | null; subscription: Webhooks.Subscription }[]> {
const rows = await db.kysely
.selectFrom('webhook_subscriptions')
.selectAll()
.where('chainId', '=', chainId)
.where('eventType', '=', eventType)
.where('status', '=', 'active')
.where(live(now))
.orderBy('id')
.execute()
return rows.map((row) => ({ cursor: row.pollerCursor, subscription: toSubscription(row) }))
}
/**
* Lists every live `active` subscription on a chain in one read. Callers
* group the rows by event type so empty streams cost no extra database reads.
*
* @param db - The database.
* @param options - Chain, configured event types, and expiry time.
* @returns Cursor-and-subscription pairs ordered by event type, then id.
*/
export async function listActiveForChain(
db: Db.Db,
options: listActiveForChain.Options,
): Promise<{ cursor: string | null; subscription: Webhooks.Subscription }[]> {
if (options.eventTypes.length === 0) return []
const rows = await db.kysely
.selectFrom('webhook_subscriptions')
.selectAll()
.where('chainId', '=', options.chainId)
.where('eventType', 'in', options.eventTypes)
.where('status', '=', 'active')
.where(live(options.now))
.orderBy('eventType')
.orderBy('id')
.execute()
return rows.map((row) => ({ cursor: row.pollerCursor, subscription: toSubscription(row) }))
}
export declare namespace listActiveForChain {
/** Filters for the chain-level subscription read. */
type Options = {
/** Chain whose subscriptions to read. */
chainId: number
/** Event types eligible for the read. */
eventTypes: readonly Webhooks.EventType[]
/** ISO timestamp expiry is evaluated against. */
now: string
}
}
/** Lists live funding subscriptions visible to one resource owner. */
export async function listActiveForFundingEvent(
db: Db.Db,
options: listActiveForFundingEvent.Options,
): Promise {
let query = db.kysely
.selectFrom('webhook_subscriptions')
.selectAll()
.where('chainId', '=', options.chainId)
.where('environment', '=', options.environment)
.where('eventType', '=', options.eventType)
.where('ownerId', '=', options.orgId)
.where('ownerType', '=', 'api_key')
.where('status', '=', 'active')
.where(live(options.now))
if (options.eventCreatedAt !== undefined)
query = query.where('createdAt', '<=', options.eventCreatedAt)
const projectId = options.projectId
query =
projectId === undefined
? query.where('projectId', 'is', null)
: query.where((eb) => eb.or([eb('projectId', 'is', null), eb('projectId', '=', projectId)]))
// Keep subscriptions present until their staged queue events commit.
return (await query.orderBy('id').forKeyShare().execute()).map(toSubscription)
}
export declare namespace listActiveForFundingEvent {
/** Ownership scope and chain for a funding resource event. */
type Options = {
/** Destination Tempo chain id. */
chainId: number
/** Resource API-key environment. */
environment: 'production' | 'sandbox'
/** Event timestamp used to keep subscriptions future-only, when supplied. */
eventCreatedAt?: string | undefined
/** Funding event type to match. */
eventType: Extract
/** ISO timestamp used to exclude expired subscriptions. */
now: string
/** Owning organization id. */
orgId: string
/** Attributed funding resource project, when present. */
projectId?: string | undefined
}
}
/**
* Reads a subscription's keyset cursor.
*
* @param db - The database.
* @param id - The subscription id (`wh_…`).
* @returns The cursor, or `null` when unset (or the row is absent).
*/
export async function getCursor(db: Db.Db, id: string): Promise {
const row = await db.kysely
.selectFrom('webhook_subscriptions')
.select('pollerCursor')
.where('id', '=', id)
.executeTakeFirst()
return row?.pollerCursor ?? null
}
/**
* Records a successful delivery: resets the consecutive-failure counter and
* stamps `lastDeliveryAt`. Skipped when neither field would move, so
* deliveries stop contending on the row's lock.
*
* @param db - The database.
* @param id - The subscription id (`wh_…`).
* @param options - Delivery timestamp and stamp-staleness bound.
* @returns The updated subscription, or `undefined` when nothing was written.
*/
export async function recordSuccess(
db: Db.Db,
id: string,
options: recordSuccess.Options,
): Promise {
const { now, staleBefore } = options
const row = await db.kysely
.updateTable('webhook_subscriptions')
.set({ failureCount: 0, lastDeliveryAt: now, updatedAt: now })
.where('id', '=', id)
.where((eb) =>
eb.or([
eb('failureCount', '>', 0),
eb('lastDeliveryAt', 'is', null),
eb('lastDeliveryAt', '<', staleBefore),
]),
)
.returningAll()
.executeTakeFirst()
return row ? toSubscription(row) : undefined
}
export declare namespace recordSuccess {
/** Clock bounds for one success record. */
type Options = {
/** Delivery timestamp (ISO). */
now: string
/** Rewrite the stamp only when it predates this (ISO). */
staleBefore: string
}
}
/**
* Records a failed delivery: atomically increments the consecutive-failure
* counter and disables the subscription once it reaches `maxFailures` (raw
* fragments use storage column names — they bypass the camelCase mapping).
* Skipped once disabled at the cap, when nothing would move.
*
* @param db - The database.
* @param id - The subscription id (`wh_…`).
* @param maxFailures - Consecutive failures before auto-disabling.
* @param now - Failure timestamp (ISO).
* @returns The updated subscription, or `undefined` when nothing was written.
*/
export async function recordFailure(
db: Db.Db,
id: string,
maxFailures: number,
now: string,
): Promise {
const row = await db.kysely
.updateTable('webhook_subscriptions')
.set({
failureCount: sql`failure_count + 1`,
status: sql`CASE WHEN failure_count + 1 >= ${maxFailures} THEN 'disabled' ELSE status END`,
updatedAt: now,
})
.where('id', '=', id)
// In-flight failures race the disable and would queue on the row lock. A
// re-enabled subscription keeps its capped counter; the status arm lets
// its next failure re-disable it.
.where((eb) => eb.or([eb('failureCount', '<', maxFailures), eb('status', '!=', 'disabled')]))
.returningAll()
.executeTakeFirst()
return row ? toSubscription(row) : undefined
}
// Smaller than the ledger-table bounds: each subscription delete cascades
// into deliveries, queue events, and completions, fanning the statement out.
const pruneBatchLimit = 1_000
/**
* Deletes expired subscriptions (their delivery rows cascade). Called
* best-effort on a schedule.
*
* @param db - The database.
* @param now - ISO timestamp expiry is evaluated against.
* @returns The number of rows deleted.
*/
export async function pruneExpired(db: Db.Db, now: string): Promise {
// Bounded like the ledger prunes: this delete cascades into deliveries,
// queue events, and completions, so it fans out the widest of the four.
const result = await db.kysely
.deleteFrom('webhook_subscriptions')
// Repeated on the outer delete: the row recheck under concurrency re-runs
// only this predicate, so a subscription renewed mid-statement survives.
.where('expiresAt', '<=', now)
.where('id', 'in', (eb) =>
eb
.selectFrom('webhook_subscriptions')
.select('id')
.where('expiresAt', 'is not', null)
.where('expiresAt', '<=', now)
.limit(pruneBatchLimit),
)
.executeTakeFirst()
return Number(result.numDeletedRows)
}
/** Live-row predicate: no expiry, or expiry strictly in the future. */
function live(now: string) {
return (eb: ExpressionBuilder) =>
eb.or([eb('expiresAt', 'is', null), eb('expiresAt', '>', now)])
}