@objectstack/plugin-webhooks 5.1.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +35 -13
- package/CHANGELOG.md +17 -33
- package/dist/chunk-33LYZT7O.js +184 -0
- package/dist/chunk-33LYZT7O.js.map +1 -0
- package/dist/chunk-BS2QTZH3.js +256 -0
- package/dist/chunk-BS2QTZH3.js.map +1 -0
- package/dist/chunk-FA66GQEO.cjs +256 -0
- package/dist/chunk-FA66GQEO.cjs.map +1 -0
- package/dist/chunk-MJZGD37S.cjs +184 -0
- package/dist/chunk-MJZGD37S.cjs.map +1 -0
- package/dist/index.cjs +908 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +469 -0
- package/dist/index.d.ts +435 -70
- package/dist/index.js +872 -217
- package/dist/index.js.map +1 -1
- package/dist/outbox-CIn7LSyB.d.cts +155 -0
- package/dist/outbox-CIn7LSyB.d.ts +155 -0
- package/dist/schema.cjs +9 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +4787 -0
- package/dist/schema.d.ts +4787 -0
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-outbox.cjs +8 -0
- package/dist/sql-outbox.cjs.map +1 -0
- package/dist/sql-outbox.d.cts +55 -0
- package/dist/sql-outbox.d.ts +55 -0
- package/dist/sql-outbox.js +8 -0
- package/dist/sql-outbox.js.map +1 -0
- package/package.json +30 -10
- package/src/auto-enqueuer.test.ts +391 -0
- package/src/auto-enqueuer.ts +335 -0
- package/src/dispatcher.test.ts +324 -0
- package/src/dispatcher.ts +218 -0
- package/src/http-sender.ts +187 -0
- package/src/index.ts +49 -12
- package/src/memory-outbox.test.ts +86 -0
- package/src/memory-outbox.ts +155 -0
- package/src/outbox.ts +175 -0
- package/src/partition.ts +19 -0
- package/src/retention.test.ts +116 -0
- package/src/retention.ts +144 -0
- package/src/schema.ts +22 -0
- package/src/sql-outbox.test.ts +490 -0
- package/src/sql-outbox.ts +343 -0
- package/src/sys-webhook-delivery.object.ts +224 -0
- package/src/webhook-outbox-plugin.ts +442 -0
- package/tsconfig.json +5 -13
- package/tsup.config.ts +14 -0
- package/dist/index.d.mts +0 -104
- package/dist/index.mjs +0 -216
- package/dist/index.mjs.map +0 -1
- package/src/webhooks-plugin.test.ts +0 -218
- package/src/webhooks-plugin.ts +0 -294
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/index.cjs","../src/webhook-outbox-plugin.ts","../src/auto-enqueuer.ts","../src/http-sender.ts","../src/dispatcher.ts","../src/memory-outbox.ts","../src/retention.ts"],"names":[],"mappings":"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","file":"/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type {\n IClusterService,\n IDataEngine,\n IRealtimeService,\n} from '@objectstack/spec/contracts';\nimport { SysWebhook } from '@objectstack/platform-objects/integration';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';\nimport { MemoryWebhookOutbox } from './memory-outbox.js';\nimport type { IWebhookOutbox } from './outbox.js';\nimport {\n DeliveryRetentionSweeper,\n type DeliveryRetentionOptions,\n} from './retention.js';\nimport { SqlWebhookOutbox } from './sql-outbox.js';\nimport { SysWebhookDelivery } from './sys-webhook-delivery.object.js';\n\nexport interface WebhookOutboxPluginOptions\n extends Partial<Omit<DispatcherOptions, 'cluster' | 'outbox' | 'nodeId'>> {\n /**\n * Override the outbox backend. If omitted a fresh `MemoryWebhookOutbox`\n * is used — fine for local development, **not for production**: each\n * node will see only its own rows.\n *\n * Pass a factory if you need the kernel-resolved `IDataEngine`:\n *\n * ```ts\n * outbox: (ctx) => new SqlWebhookOutbox(\n * ctx.getService('objectql'), { partitionCount: 8 },\n * ),\n * ```\n */\n outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);\n\n /**\n * Stable node id. If omitted, uses `process.env.OBJECTSTACK_NODE_ID`\n * or a random UUID generated at plugin init.\n */\n nodeId?: string;\n\n /**\n * If `false`, the plugin registers the outbox/dispatcher services but\n * does NOT auto-start the loop — useful for tests that want to step\n * the dispatcher manually via `dispatcher.tick()`.\n *\n * Default: true.\n */\n autoStart?: boolean;\n\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime\n * + data engine services are available), the plugin subscribes to\n * `data.record.*` events emitted by the engine and automatically\n * enqueues a delivery row for every matching `sys_webhook` row.\n *\n * Set `false` to disable and only use the imperative\n * `outbox.enqueue()` API.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n\n /**\n * Retention sweep config. When enabled (default `true` if a SQL\n * outbox is in use), a periodic timer prunes old `success` and\n * `dead` rows from `sys_webhook_delivery`.\n *\n * Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).\n */\n retention?: boolean | DeliveryRetentionOptions;\n}\n\n/**\n * Wires a persistent, cluster-aware webhook outbox into the kernel.\n *\n * Registered services:\n * - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)\n * - `webhook.dispatcher` → `WebhookDispatcher` (manual `tick()` if needed)\n * - `webhook.autoEnqueuer` → `AutoEnqueuer` when auto-enqueue is on\n * - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on\n *\n * End-to-end flow once auto-enqueue is enabled:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → outbox.enqueue() runs fire-and-forget (not on the write path)\n * → dispatcher claims and POSTs (cluster-coordinated)\n *\n * **Cluster requirement** — this plugin depends on the cluster service\n * (`ClusterServicePlugin`). With the default `memory` driver the\n * dispatcher works correctly inside a single process; with a real driver\n * (`@objectstack/service-cluster-redis`) it correctly coordinates work\n * across nodes.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '1.1.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.cluster'];\n\n private dispatcher: WebhookDispatcher | undefined;\n private autoEnqueuer: AutoEnqueuer | undefined;\n private retention: DeliveryRetentionSweeper | undefined;\n private outboxInstance: IWebhookOutbox | undefined;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n const cluster = ctx.getService<IClusterService>('cluster');\n if (!cluster) {\n throw new Error(\n 'WebhookOutboxPlugin: required service \"cluster\" not found — register ClusterServicePlugin first',\n );\n }\n\n // Register the schemas this plugin owns at runtime. `sys_webhook`\n // (config) lives in @objectstack/platform-objects but no other\n // plugin claims it — the webhook plugin is the natural owner\n // since it's the consumer of those rows. `sys_webhook_delivery`\n // (telemetry) is plugin-private. Registering them here means a\n // stack just needs `plugins: [new WebhookOutboxPlugin(...)]`\n // and both objects auto-appear in REST/Studio/Setup nav.\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Outbox Schemas',\n description:\n 'Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).',\n objects: [SysWebhook, SysWebhookDelivery],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook / sys_webhook_delivery will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n const outbox = this.resolveOutbox(ctx);\n this.outboxInstance = outbox;\n const nodeId =\n this.options.nodeId ??\n process.env.OBJECTSTACK_NODE_ID ??\n `node-${Math.random().toString(36).slice(2, 10)}`;\n\n const dispatcher = new WebhookDispatcher({\n nodeId,\n cluster,\n outbox,\n partitionCount: this.options.partitionCount,\n batchSize: this.options.batchSize,\n intervalMs: this.options.intervalMs,\n lockTtlMs: this.options.lockTtlMs,\n claimTtlMs: this.options.claimTtlMs,\n fetchImpl: this.options.fetchImpl,\n onAttempt: this.options.onAttempt,\n rng: this.options.rng,\n logger: ctx.logger,\n });\n this.dispatcher = dispatcher;\n\n ctx.registerService('webhook.outbox', outbox);\n ctx.registerService('webhook.dispatcher', dispatcher);\n\n if (this.options.autoStart !== false) {\n dispatcher.start();\n }\n\n // Loud warning when running with the in-memory outbox in production —\n // it loses data on restart and never shares rows across nodes. With\n // the auto-pick logic above this only fires when no IDataEngine is\n // available, but flag it loudly anyway.\n const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;\n if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {\n ctx.logger.warn?.(\n '[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Ensure ObjectQL is registered before WebhookOutboxPlugin so the SQL outbox can auto-select.',\n );\n }\n\n // Auto-enqueue + retention need the kernel to be fully ready\n // before ObjectQL / Realtime services are resolvable.\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n const retentionOpt = this.options.retention ?? true;\n\n const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;\n if (needsReadyHook && typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.bootRetention(ctx, retentionOpt);\n });\n }\n\n // Admin REST endpoint — POST /api/v1/webhooks/redeliver { deliveryId }.\n // Wired in `kernel:ready` so the auth + http services are guaranteed\n // resolvable. Gated on a session cookie so anonymous callers cannot\n // replay deliveries; finer-grained RBAC (e.g. \"only admins\") can be\n // layered on later — for now any signed-in user with access to the\n // Setup app can redeliver. The action is also `disabled`-gated by\n // status on the Studio side so the button only lights up on\n // success / failed / dead rows.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', () => {\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised', {\n nodeId,\n partitions: this.options.partitionCount ?? 8,\n interval: this.options.intervalMs ?? 250,\n autoEnqueue: autoEnqueueOpt !== false,\n retention: retentionOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n this.retention?.stop();\n await this.dispatcher?.stop();\n }\n\n private resolveOutbox(ctx: PluginContext): IWebhookOutbox {\n const opt = this.options.outbox;\n if (opt) {\n return typeof opt === 'function'\n ? (opt as (c: PluginContext) => IWebhookOutbox)(ctx)\n : opt;\n }\n // No explicit override — auto-pick the right backend for the host.\n // SqlWebhookOutbox needs an `IDataEngine`; if one is resolvable\n // (the usual case in CLI-served stacks), use it so durable rows\n // in `sys_webhook_delivery` actually round-trip through the\n // dispatcher and the redeliver REST endpoint. Memory is only a\n // last-resort fallback for tests / edge environments without an\n // engine.\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (engine) {\n const partitionCount = this.options.partitionCount ?? 8;\n const sql = new SqlWebhookOutbox(engine, { partitionCount });\n ctx.logger.info?.(\n '[webhook-outbox] auto-selected SqlWebhookOutbox (objectql engine detected)',\n { partitionCount },\n );\n return sql;\n }\n ctx.logger.warn?.(\n '[webhook-outbox] no IDataEngine available — falling back to MemoryWebhookOutbox. Deliveries will NOT survive process restart and the redeliver REST endpoint will not see rows written through ObjectQL.',\n );\n return new MemoryWebhookOutbox();\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n if (!engine || !realtime) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL or Realtime service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime },\n );\n return;\n }\n if (!this.outboxInstance) return;\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n this.outboxInstance,\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started');\n }\n\n private bootRetention(\n ctx: PluginContext,\n opt: boolean | DeliveryRetentionOptions,\n ): void {\n if (opt === false) return;\n // Only meaningful for SQL outbox — Memory has its own (process-lifetime) GC.\n if (this.outboxInstance instanceof MemoryWebhookOutbox) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (!engine) {\n ctx.logger.warn?.(\n '[webhook-retention] disabled — ObjectQL service not available',\n );\n return;\n }\n const retOpts = (typeof opt === 'object' ? opt : {}) as DeliveryRetentionOptions;\n this.retention = new DeliveryRetentionSweeper(engine, {\n ...retOpts,\n logger: ctx.logger,\n });\n this.retention.start();\n ctx.registerService('webhook.retention', this.retention);\n ctx.logger.info?.('[webhook-retention] sweeper started');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one\n * is available. Silently no-ops in environments without an HTTP\n * server (MSW, edge tests, pure library use). Auth is delegated to\n * the better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.(\n '[webhook-outbox] HTTP server not available; redeliver REST endpoint not mounted',\n );\n return;\n }\n const rawApp = http.getRawApp();\n const outbox = this.outboxInstance;\n if (!rawApp || !outbox) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n // Auth gate — require a signed-in session.\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n {\n success: false,\n error: 'unauthenticated',\n message: 'Sign in to redeliver webhook deliveries.',\n },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json(\n {\n success: false,\n error: 'invalid_body',\n message: 'Request body must be JSON.',\n },\n 400,\n );\n }\n const deliveryId =\n typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n {\n success: false,\n error: 'missing_delivery_id',\n message: 'Body must include `deliveryId: string`.',\n },\n 400,\n );\n }\n try {\n const row = await outbox.redeliver(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', {\n deliveryId,\n requestedBy: userId,\n });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'not_found') {\n return c.json(\n { success: false, error: 'not_found', message: err.message },\n 404,\n );\n }\n if (code === 'not_eligible') {\n return c.json(\n { success: false, error: 'not_eligible', message: err.message },\n 409,\n );\n }\n ctx.logger.error?.(\n '[webhook-outbox] redeliver failed',\n err as Error,\n );\n return c.json(\n {\n success: false,\n error: 'internal_error',\n message: err?.message ?? String(err),\n },\n 500,\n );\n }\n });\n\n ctx.logger.info?.(\n '[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver',\n );\n }\n\n /**\n * Resolve the requesting user's id from a better-auth session cookie.\n * Returns `undefined` for anonymous callers — the caller decides\n * whether that's a 401.\n */\n private async resolveSessionUserId(\n ctx: PluginContext,\n c: any,\n ): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { IWebhookOutbox } from './outbox.js';\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects (manual-only is filtered out earlier)\n triggers: Set<'create' | 'update' | 'delete' | 'undelete'>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly outbox: IWebhookOutbox,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n const triggersField = (row.triggers ?? '') as string;\n const triggers = new Set(\n triggersField\n .split(',')\n .map((s: string) => s.trim().toLowerCase())\n .filter(Boolean) as Array<'create' | 'update' | 'delete' | 'undelete'>,\n );\n if (triggers.size === 0) {\n // Manual-only webhook (no triggers) — skip auto-enqueue.\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (headers,\n // secret, timeout); attempt a best-effort parse. Fall back to\n // top-level fields where present.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n method: row.method ?? defn.method ?? 'POST',\n headers: defn.headers,\n secret: defn.secret,\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.type?.startsWith('data.record.')) return;\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | 'undeleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n const payload = event.payload ?? {};\n const recordId =\n (payload as any).recordId ??\n (payload as any).id ??\n (payload as any).after?.id ??\n (payload as any).before?.id ??\n 'unknown';\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path.\n void this.outbox\n .enqueue({\n webhookId: sub.id,\n eventId,\n eventType: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n secret: sub.secret,\n timeoutMs: sub.timeoutMs,\n payload: {\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n ...payload,\n },\n })\n .catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n if (!event.type?.startsWith('data.record.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | 'undelete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n case 'undeleted':\n return 'undelete';\n default:\n return null;\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport { createHmac, randomUUID } from 'node:crypto';\nimport type { WebhookDelivery, AckResult } from './outbox.js';\n\n/**\n * Default per-request timeout. Receivers SHOULD respond within ~30s; we\n * cap aggressively to free dispatcher slots.\n */\nexport const DEFAULT_TIMEOUT_MS = 15_000;\n\n/** Truncate response bodies to keep storage cost predictable. */\nconst RESPONSE_BODY_CAP = 16 * 1024;\n\nexport type FetchImpl = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n body: string;\n signal: AbortSignal;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n text(): Promise<string>;\n}>;\n\n/** Single HTTP attempt classified to an `AckResult` shape (without nextRetryAt). */\nexport type AttemptOutcome =\n | { success: true; httpStatus: number; responseBody?: string; durationMs: number }\n | {\n success: false;\n retriable: boolean;\n httpStatus?: number;\n responseBody?: string;\n error?: string;\n durationMs: number;\n };\n\n/**\n * Send one HTTP attempt for the delivery. Pure (no DB writes) so the\n * dispatcher owns retry-schedule + ack logic.\n *\n * - 2xx → success\n * - 4xx (except 408/429) → permanent failure (retriable = false → goes to `dead`)\n * - 408, 429, 5xx, transport → retriable\n */\nexport async function sendOnce(\n delivery: WebhookDelivery,\n fetchImpl: FetchImpl,\n): Promise<AttemptOutcome> {\n const body =\n typeof delivery.payload === 'string'\n ? delivery.payload\n : JSON.stringify(delivery.payload);\n\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n 'User-Agent': 'ObjectStack-Webhooks/1.0',\n 'X-Objectstack-Event': delivery.eventType,\n 'X-Objectstack-Delivery': delivery.id,\n 'X-Objectstack-Attempt': String(delivery.attempts + 1),\n ...(delivery.headers ?? {}),\n };\n if (delivery.secret) {\n const sig = createHmac('sha256', delivery.secret).update(body).digest('hex');\n headers['X-Objectstack-Signature'] = `sha256=${sig}`;\n }\n\n const timeoutMs = delivery.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n const start = Date.now();\n try {\n const res = await fetchImpl(delivery.url, {\n method: delivery.method ?? 'POST',\n headers,\n body,\n signal: controller.signal,\n });\n clearTimeout(timer);\n const responseText = await safeReadBody(res);\n const durationMs = Date.now() - start;\n if (res.ok) {\n return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };\n }\n const retriable = res.status === 408 || res.status === 429 || res.status >= 500;\n return {\n success: false,\n retriable,\n httpStatus: res.status,\n responseBody: responseText,\n error: `HTTP ${res.status}`,\n durationMs,\n };\n } catch (err: unknown) {\n clearTimeout(timer);\n const durationMs = Date.now() - start;\n const e = err as { name?: string; message?: string };\n const error = e?.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);\n return { success: false, retriable: true, error, durationMs };\n }\n}\n\nasync function safeReadBody(res: { text(): Promise<string> }): Promise<string | undefined> {\n try {\n const text = await res.text();\n return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Stripe-style retry schedule. Returns the next `nextRetryAt` ms (relative\n * to `now`) given how many attempts have already happened, or `null` if\n * the row should be moved to `dead`.\n *\n * attempt 1 fails -> retry in ~1s\n * attempt 2 fails -> ~10s\n * attempt 3 fails -> ~1m\n * attempt 4 fails -> ~10m\n * attempt 5 fails -> ~1h\n * attempt 6 fails -> ~6h\n * attempt 7 fails -> ~24h\n * attempt 8+ fails -> dead\n *\n * Each delay is multiplied by jitter ∈ [0.8, 1.2].\n */\nexport function nextRetryDelayMs(\n attemptsSoFar: number,\n rng: () => number = Math.random,\n): number | null {\n const SCHEDULE = [1_000, 10_000, 60_000, 600_000, 3_600_000, 21_600_000, 86_400_000];\n if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;\n const base = SCHEDULE[attemptsSoFar - 1];\n const jitter = 0.8 + rng() * 0.4;\n return Math.floor(base * jitter);\n}\n\n/**\n * Compose an `AckResult` from an `AttemptOutcome`, applying the retry\n * schedule on retriable failures.\n */\nexport function classifyAttempt(\n outcome: AttemptOutcome,\n attemptsSoFar: number,\n now: number = Date.now(),\n rng?: () => number,\n): AckResult {\n if (outcome.success) return outcome;\n if (!outcome.retriable) {\n return {\n success: false,\n httpStatus: outcome.httpStatus,\n responseBody: outcome.responseBody,\n error: outcome.error,\n durationMs: outcome.durationMs,\n dead: true,\n };\n }\n const delay = nextRetryDelayMs(attemptsSoFar + 1, rng);\n if (delay === null) {\n return {\n success: false,\n httpStatus: outcome.httpStatus,\n responseBody: outcome.responseBody,\n error: outcome.error,\n durationMs: outcome.durationMs,\n dead: true,\n };\n }\n return {\n success: false,\n httpStatus: outcome.httpStatus,\n responseBody: outcome.responseBody,\n error: outcome.error,\n durationMs: outcome.durationMs,\n nextRetryAt: now + delay,\n };\n}\n\n/** Generate a fresh delivery id (UUID v4). Exposed for tests. */\nexport function newDeliveryId(): string {\n return randomUUID();\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IClusterService, LockHandle } from '@objectstack/spec/contracts';\nimport type { FetchImpl } from './http-sender.js';\nimport { classifyAttempt, sendOnce } from './http-sender.js';\nimport type { IWebhookOutbox, WebhookDelivery } from './outbox.js';\n\n/**\n * Minimal logger surface — kernel's `Logger` is compatible (extra params\n * accepted). Keeping it permissive avoids a hard dependency on the spec\n * Logger interface here.\n */\nexport interface DispatcherLogger {\n warn: (msg: string, meta?: any) => void;\n info?: (msg: string, meta?: any) => void;\n}\n\nexport interface DispatcherOptions {\n /** Stable id identifying this dispatcher node. */\n nodeId: string;\n /** Cluster service providing `lock` (and optional metrics). */\n cluster: IClusterService;\n /** Outbox backend. */\n outbox: IWebhookOutbox;\n /**\n * How many partitions to split work across. Each tick the dispatcher\n * attempts to acquire each partition's lock independently — the node\n * that wins owns that partition for the duration of the batch.\n *\n * Default: 8 (matches webhook-delivery.mdx §4 example).\n */\n partitionCount?: number;\n /** Max rows to claim from each partition per tick. Default 32. */\n batchSize?: number;\n /** Tick interval in ms. Default 250. */\n intervalMs?: number;\n /** Per-partition lock TTL. Default = 5 × intervalMs. */\n lockTtlMs?: number;\n /** Visibility timeout for claimed rows. Default = 2 × lockTtlMs. */\n claimTtlMs?: number;\n /** Override `globalThis.fetch` (tests). */\n fetchImpl?: FetchImpl;\n /** Hook fired after every attempt — observability hook. */\n onAttempt?: (delivery: WebhookDelivery, success: boolean) => void;\n /** RNG override for the retry-jitter schedule (tests). */\n rng?: () => number;\n /** Logger callback (optional). */\n logger?: DispatcherLogger;\n}\n\n/**\n * Cross-node webhook dispatcher.\n *\n * **Design** — each tick the dispatcher iterates over `partitionCount`\n * logical partitions. For each, it tries to acquire a cluster-scoped lock\n * (`webhook.dispatcher.partition.{i}`) with a short TTL. If it wins the\n * lock, it claims up to `batchSize` ready rows whose `hash(webhookId) mod\n * partitionCount === i`, POSTs them, and acks. The lock is released\n * immediately after the batch so other nodes can fairly rotate through.\n *\n * **Why per-partition locks rather than one global lock?**\n *\n * 1. Throughput — N nodes can process N partitions concurrently.\n * 2. Partition affinity — rows for the same webhook always sort into the\n * same partition, preserving in-order delivery per webhook.\n * 3. Failure isolation — a stuck node only blocks its partition until the\n * TTL elapses; other partitions keep moving.\n *\n * **At-least-once, not exactly-once.** Receivers MUST be idempotent on the\n * `X-Objectstack-Delivery` (== row id) header. If the HTTP call succeeds\n * but the ack write fails, the row reverts to pending after the claim TTL\n * and will be re-posted.\n */\nexport class WebhookDispatcher {\n private readonly opts: Required<\n Omit<DispatcherOptions, 'onAttempt' | 'fetchImpl' | 'rng' | 'logger'>\n > & Pick<DispatcherOptions, 'onAttempt' | 'fetchImpl' | 'rng' | 'logger'>;\n private timer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private inflightTick: Promise<void> | undefined;\n\n constructor(options: DispatcherOptions) {\n const intervalMs = options.intervalMs ?? 250;\n const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;\n this.opts = {\n nodeId: options.nodeId,\n cluster: options.cluster,\n outbox: options.outbox,\n partitionCount: options.partitionCount ?? 8,\n batchSize: options.batchSize ?? 32,\n intervalMs,\n lockTtlMs,\n claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,\n onAttempt: options.onAttempt,\n fetchImpl: options.fetchImpl,\n rng: options.rng,\n logger: options.logger,\n };\n }\n\n /** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */\n start(): void {\n if (this.running) return;\n this.running = true;\n // Fire one tick immediately so single-row tests don't wait the interval.\n this.scheduleTick();\n this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);\n }\n\n /** Stop the loop and wait for the in-flight tick to drain. */\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.timer) {\n clearInterval(this.timer);\n this.timer = undefined;\n }\n if (this.inflightTick) {\n try {\n await this.inflightTick;\n } catch {\n /* swallow — already logged */\n }\n }\n }\n\n /**\n * Run one full tick (all partitions, single attempt each). Exposed for\n * deterministic tests that want to step the dispatcher manually.\n */\n async tick(): Promise<void> {\n await this.runTick();\n }\n\n private scheduleTick(): void {\n if (this.inflightTick) return; // skip if previous tick still running\n this.inflightTick = this.runTick()\n .catch((err) => {\n this.opts.logger?.warn?.('webhook-dispatcher: tick failed', {\n nodeId: this.opts.nodeId,\n error: (err as Error)?.message ?? String(err),\n });\n })\n .finally(() => {\n this.inflightTick = undefined;\n });\n }\n\n private async runTick(): Promise<void> {\n const partitionCount = this.opts.partitionCount;\n // Walk partitions in a rotated order per node so contention spreads.\n const offset = stableNodeOffset(this.opts.nodeId, partitionCount);\n for (let step = 0; step < partitionCount; step++) {\n const i = (offset + step) % partitionCount;\n await this.runPartition(i);\n }\n }\n\n private async runPartition(index: number): Promise<void> {\n const key = `webhook.dispatcher.partition.${index}`;\n const handle: LockHandle | null = await this.opts.cluster.lock.acquire(key, {\n ttlMs: this.opts.lockTtlMs,\n // waitMs=0 → fail-fast; we'll try this partition again next tick.\n waitMs: 0,\n });\n if (!handle) return;\n\n try {\n const claimed = await this.opts.outbox.claim({\n nodeId: this.opts.nodeId,\n limit: this.opts.batchSize,\n partition: { index, count: this.opts.partitionCount },\n claimTtlMs: this.opts.claimTtlMs,\n });\n if (claimed.length === 0) return;\n // Renew before potentially long HTTP work — and bound batch time.\n await handle.renew(this.opts.lockTtlMs);\n for (const row of claimed) {\n if (!handle.isHeld()) break; // lost the lock — abandon remaining rows\n await this.processRow(row);\n }\n } finally {\n await handle.release();\n }\n }\n\n private async processRow(row: WebhookDelivery): Promise<void> {\n const fetchImpl = (this.opts.fetchImpl ?? (globalThis.fetch as unknown as FetchImpl)) as FetchImpl | undefined;\n if (!fetchImpl) {\n this.opts.logger?.warn?.('webhook-dispatcher: no fetch impl available', {\n rowId: row.id,\n });\n await this.opts.outbox.ack(row.id, {\n success: false,\n error: 'no fetch implementation',\n durationMs: 0,\n dead: true,\n });\n return;\n }\n const outcome = await sendOnce(row, fetchImpl);\n const result = classifyAttempt(outcome, row.attempts, Date.now(), this.opts.rng);\n await this.opts.outbox.ack(row.id, result);\n this.opts.onAttempt?.(row, result.success);\n }\n}\n\n/**\n * Spread starting partition per node so a 2-node cluster with 8 partitions\n * doesn't have both nodes serialise on partition 0 every tick.\n */\nfunction stableNodeOffset(nodeId: string, partitionCount: number): number {\n let h = 0;\n for (let i = 0; i < nodeId.length; i++) {\n h = (h * 31 + nodeId.charCodeAt(i)) | 0;\n }\n return Math.abs(h) % partitionCount;\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport { randomUUID } from 'node:crypto';\nimport type {\n AckResult,\n ClaimOptions,\n EnqueueInput,\n DeliveryStatus,\n IWebhookOutbox,\n WebhookDelivery,\n} from './outbox.js';\nimport { RedeliverError } from './outbox.js';\nimport { hashPartition } from './partition.js';\n\n/**\n * In-memory `IWebhookOutbox` for tests and single-process development.\n *\n * Implements the atomic-claim semantics by running its claim/ack logic\n * synchronously (single-threaded JS event loop) inside one `Map`. Two\n * `MemoryWebhookOutbox` instances do NOT share state — for the cross-node\n * test the *same* instance is passed to both dispatchers (simulating one\n * shared database).\n *\n * A production SQL-backed implementation will live in a sibling file and\n * use `SELECT ... FOR UPDATE SKIP LOCKED`.\n */\nexport class MemoryWebhookOutbox implements IWebhookOutbox {\n private readonly rows = new Map<string, WebhookDelivery>();\n /** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */\n private readonly dedup = new Map<string, string>();\n\n async enqueue(input: EnqueueInput): Promise<string> {\n const dedupKey = `${input.eventId}::${input.webhookId}`;\n const existing = this.dedup.get(dedupKey);\n if (existing) return existing;\n\n const id = randomUUID();\n const now = Date.now();\n const row: WebhookDelivery = {\n id,\n webhookId: input.webhookId,\n eventId: input.eventId,\n eventType: input.eventType,\n url: input.url,\n method: input.method ?? 'POST',\n headers: input.headers,\n secret: input.secret,\n timeoutMs: input.timeoutMs,\n payload: input.payload,\n status: 'pending',\n attempts: 0,\n createdAt: now,\n updatedAt: now,\n };\n this.rows.set(id, row);\n this.dedup.set(dedupKey, id);\n return id;\n }\n\n async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {\n const now = opts.now ?? Date.now();\n const claimed: WebhookDelivery[] = [];\n\n // First pass: reap expired in_flight rows (visibility timeout).\n for (const row of this.rows.values()) {\n if (\n row.status === 'in_flight' &&\n row.claimedAt !== undefined &&\n now - row.claimedAt > opts.claimTtlMs\n ) {\n row.status = 'pending';\n row.claimedBy = undefined;\n row.claimedAt = undefined;\n row.updatedAt = now;\n }\n }\n\n for (const row of this.rows.values()) {\n if (claimed.length >= opts.limit) break;\n if (row.status !== 'pending') continue;\n if (row.nextRetryAt !== undefined && row.nextRetryAt > now) continue;\n if (opts.partition) {\n const p = hashPartition(row.webhookId, opts.partition.count);\n if (p !== opts.partition.index) continue;\n }\n row.status = 'in_flight';\n row.claimedBy = opts.nodeId;\n row.claimedAt = now;\n row.updatedAt = now;\n claimed.push({ ...row });\n }\n return claimed;\n }\n\n async ack(id: string, result: AckResult): Promise<void> {\n const row = this.rows.get(id);\n if (!row) return;\n const now = Date.now();\n row.attempts += 1;\n row.lastAttemptedAt = now;\n row.updatedAt = now;\n row.claimedBy = undefined;\n row.claimedAt = undefined;\n row.responseCode = result.httpStatus;\n row.responseBody = result.responseBody;\n\n let status: DeliveryStatus;\n if (result.success) {\n status = 'success';\n row.nextRetryAt = undefined;\n row.error = undefined;\n } else if (result.dead) {\n status = 'dead';\n row.error = result.error;\n row.nextRetryAt = undefined;\n } else {\n status = 'pending';\n row.error = result.error;\n row.nextRetryAt = result.nextRetryAt;\n }\n row.status = status;\n }\n\n async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {\n const all = Array.from(this.rows.values()).map((r) => ({ ...r }));\n return filter?.status ? all.filter((r) => r.status === filter.status) : all;\n }\n\n async redeliver(id: string): Promise<WebhookDelivery> {\n const row = this.rows.get(id);\n if (!row) {\n throw new RedeliverError(\n `Delivery row '${id}' not found`,\n 'not_found',\n );\n }\n if (row.status !== 'success' && row.status !== 'failed' && row.status !== 'dead') {\n throw new RedeliverError(\n `Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,\n 'not_eligible',\n );\n }\n const now = Date.now();\n row.status = 'pending';\n row.attempts = 0;\n row.claimedBy = undefined;\n row.claimedAt = undefined;\n row.nextRetryAt = undefined;\n row.error = undefined;\n row.responseCode = undefined;\n row.responseBody = undefined;\n row.updatedAt = now;\n return { ...row };\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport { SYS_WEBHOOK_DELIVERY } from './schema.js';\n\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n}\n\nexport interface DeliveryRetentionOptions {\n /**\n * Object name backing the outbox. Defaults to `sys_webhook_delivery`.\n */\n objectName?: string;\n\n /**\n * How long to keep `success` rows. Default 7 days. Set to `0` to\n * disable the success sweep (keep forever — not recommended in\n * production).\n */\n successTtlMs?: number;\n\n /**\n * How long to keep `dead` rows. Default 30 days. Set to `0` to\n * keep forever.\n */\n deadTtlMs?: number;\n\n /**\n * How often to run the sweep. Default 1h.\n */\n sweepIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\nconst DEFAULTS = {\n successTtlMs: 7 * 24 * 60 * 60 * 1000,\n deadTtlMs: 30 * 24 * 60 * 60 * 1000,\n sweepIntervalMs: 60 * 60 * 1000,\n};\n\n/**\n * Periodically prunes `sys_webhook_delivery` rows so the table doesn't\n * grow unbounded.\n *\n * Without this every successful POST would leave a permanent row. At\n * even moderate scale (10 events/s × 3 webhooks = 30 rows/s = ~2.6M\n * rows/day) the table becomes a problem within a week.\n *\n * Retention defaults mirror Stripe/GitHub:\n * - `success`: 7 days\n * - `dead`: 30 days (kept longer for audit & manual re-delivery)\n * - `pending`/`in_flight`/`failed`: never auto-pruned (they're\n * either live work or signal something needs human attention)\n *\n * Runs on whichever node holds the sweeper interval — it doesn't need\n * a cluster lock because DELETE WHERE created_at < threshold is\n * idempotent; multiple nodes running concurrently is wasteful but\n * safe.\n */\nexport class DeliveryRetentionSweeper {\n private readonly objectName: string;\n private readonly successTtlMs: number;\n private readonly deadTtlMs: number;\n private readonly sweepIntervalMs: number;\n private readonly logger: OptionalLogger;\n private timer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n\n constructor(\n private readonly engine: IDataEngine,\n opts: DeliveryRetentionOptions = {},\n ) {\n this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;\n this.successTtlMs = opts.successTtlMs ?? DEFAULTS.successTtlMs;\n this.deadTtlMs = opts.deadTtlMs ?? DEFAULTS.deadTtlMs;\n this.sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULTS.sweepIntervalMs;\n this.logger = opts.logger ?? {};\n }\n\n start(): void {\n if (this.running) return;\n this.running = true;\n // First sweep deferred by one interval — let the system boot first.\n this.timer = setInterval(() => {\n this.sweep().catch((err) =>\n this.logger.warn?.('[webhook-retention] sweep failed', err),\n );\n }, this.sweepIntervalMs);\n this.timer.unref?.();\n }\n\n stop(): void {\n if (!this.running) return;\n this.running = false;\n if (this.timer) clearInterval(this.timer);\n this.timer = undefined;\n }\n\n /** Run one sweep immediately. Returns the number of rows deleted. */\n async sweep(now: number = Date.now()): Promise<{ success: number; dead: number }> {\n let successDeleted = 0;\n let deadDeleted = 0;\n\n if (this.successTtlMs > 0) {\n try {\n const res = await this.engine.delete(this.objectName, {\n where: {\n status: 'success',\n updated_at: { $lt: now - this.successTtlMs },\n },\n });\n successDeleted = res?.affected ?? 0;\n } catch (err) {\n this.logger.warn?.('[webhook-retention] success sweep failed', err);\n }\n }\n\n if (this.deadTtlMs > 0) {\n try {\n const res = await this.engine.delete(this.objectName, {\n where: {\n status: 'dead',\n updated_at: { $lt: now - this.deadTtlMs },\n },\n });\n deadDeleted = res?.affected ?? 0;\n } catch (err) {\n this.logger.warn?.('[webhook-retention] dead sweep failed', err);\n }\n }\n\n if (successDeleted + deadDeleted > 0) {\n this.logger.info?.('[webhook-retention] sweep complete', {\n success: successDeleted,\n dead: deadDeleted,\n });\n }\n return { success: successDeleted, dead: deadDeleted };\n }\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { Plugin, PluginContext } from '@objectstack/core';
|
|
2
|
+
import { IDataEngine, IRealtimeService, IClusterService } from '@objectstack/spec/contracts';
|
|
3
|
+
import { I as IWebhookOutbox, a as AckResult, W as WebhookDelivery, E as EnqueueInput, C as ClaimOptions, D as DeliveryStatus } from './outbox-CIn7LSyB.cjs';
|
|
4
|
+
export { A as AckFailure, b as AckSuccess, R as RedeliverError } from './outbox-CIn7LSyB.cjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Optional logger interface (subset of console / kernel logger).
|
|
8
|
+
*/
|
|
9
|
+
interface OptionalLogger$1 {
|
|
10
|
+
info?(msg: string, meta?: unknown): void;
|
|
11
|
+
warn?(msg: string, meta?: unknown): void;
|
|
12
|
+
debug?(msg: string, meta?: unknown): void;
|
|
13
|
+
error?(msg: string, err?: unknown, meta?: unknown): void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Per-row subscription cached in memory. Mirrors a subset of the
|
|
17
|
+
* `sys_webhook` object — only what the auto-enqueuer needs to match an
|
|
18
|
+
* event and build an `EnqueueInput`.
|
|
19
|
+
*/
|
|
20
|
+
interface CachedSubscription {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
objectName: string | undefined;
|
|
24
|
+
triggers: Set<'create' | 'update' | 'delete' | 'undelete'>;
|
|
25
|
+
url: string;
|
|
26
|
+
method?: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
secret?: string;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
interface AutoEnqueuerOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Object name holding webhook subscriptions. Defaults to `sys_webhook`,
|
|
34
|
+
* the platform-objects schema authored in apps.
|
|
35
|
+
*/
|
|
36
|
+
subscriptionsObject?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Periodic full-cache refresh interval (ms). Belt-and-braces in case
|
|
39
|
+
* the subscription-change event is missed. Default 60s.
|
|
40
|
+
*/
|
|
41
|
+
refreshIntervalMs?: number;
|
|
42
|
+
logger?: OptionalLogger$1;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Bridge between `IRealtimeService` (`data.record.*` events emitted by
|
|
46
|
+
* the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher
|
|
47
|
+
* picks up).
|
|
48
|
+
*
|
|
49
|
+
* ## Why a separate class
|
|
50
|
+
* Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this
|
|
51
|
+
* class owns the runtime fan-out logic + subscription cache.
|
|
52
|
+
*
|
|
53
|
+
* ## Hot path
|
|
54
|
+
* Every `engine.insert/update/delete` fires a `data.record.*` event.
|
|
55
|
+
* The handler:
|
|
56
|
+
* 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`
|
|
57
|
+
* — O(1) per event, no DB hit on the write path.
|
|
58
|
+
* 2. Calls `outbox.enqueue()` fire-and-forget for each match. The
|
|
59
|
+
* enqueue itself is a single INSERT, which runs *after* the user's
|
|
60
|
+
* request has already returned.
|
|
61
|
+
*
|
|
62
|
+
* Net cost on the write path: one synchronous Map lookup (~microseconds).
|
|
63
|
+
*
|
|
64
|
+
* ## Cache freshness
|
|
65
|
+
* The cache is rebuilt:
|
|
66
|
+
* 1. Once on `start()`.
|
|
67
|
+
* 2. On every `data.record.{created,updated,deleted}` event whose
|
|
68
|
+
* object is `sys_webhook` (self-healing — when a user toggles a
|
|
69
|
+
* webhook, the handler refreshes the cache before returning).
|
|
70
|
+
* 3. Periodically (default 60s) as belt-and-braces.
|
|
71
|
+
*
|
|
72
|
+
* For multi-node clusters this is *eventually consistent* — node B may
|
|
73
|
+
* not see node A's edit for up to one cycle. That's acceptable for
|
|
74
|
+
* webhook configuration changes (humans don't expect millisecond
|
|
75
|
+
* propagation) and matches Hasura's behaviour.
|
|
76
|
+
*
|
|
77
|
+
* ## Determinism
|
|
78
|
+
* `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`
|
|
79
|
+
* so the outbox dedup index catches duplicates that could arise from
|
|
80
|
+
* upstream replay or buggy producers — and is stable across nodes.
|
|
81
|
+
*/
|
|
82
|
+
declare class AutoEnqueuer {
|
|
83
|
+
private readonly engine;
|
|
84
|
+
private readonly realtime;
|
|
85
|
+
private readonly outbox;
|
|
86
|
+
private readonly subscriptions;
|
|
87
|
+
private readonly subscriptionsObject;
|
|
88
|
+
private readonly refreshIntervalMs;
|
|
89
|
+
private readonly logger;
|
|
90
|
+
private subId;
|
|
91
|
+
private subIdSelfHeal;
|
|
92
|
+
private refreshTimer;
|
|
93
|
+
private running;
|
|
94
|
+
private refreshing;
|
|
95
|
+
constructor(engine: IDataEngine, realtime: IRealtimeService, outbox: IWebhookOutbox, opts?: AutoEnqueuerOptions);
|
|
96
|
+
/**
|
|
97
|
+
* Load the subscription cache and start listening for events.
|
|
98
|
+
*/
|
|
99
|
+
start(): Promise<void>;
|
|
100
|
+
stop(): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Force-refresh the subscription cache from storage. Concurrent
|
|
103
|
+
* callers share a single in-flight refresh.
|
|
104
|
+
*/
|
|
105
|
+
refresh(): Promise<void>;
|
|
106
|
+
private doRefresh;
|
|
107
|
+
private parseRow;
|
|
108
|
+
/**
|
|
109
|
+
* Handler for the firehose subscription.
|
|
110
|
+
*
|
|
111
|
+
* NOTE: we intentionally `void` the inner enqueue() so the realtime
|
|
112
|
+
* publisher (and therefore the user's request) is never blocked on
|
|
113
|
+
* webhook persistence.
|
|
114
|
+
*/
|
|
115
|
+
private handleEvent;
|
|
116
|
+
private handleSelfHealEvent;
|
|
117
|
+
/** Test / admin accessor. */
|
|
118
|
+
snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Default per-request timeout. Receivers SHOULD respond within ~30s; we
|
|
123
|
+
* cap aggressively to free dispatcher slots.
|
|
124
|
+
*/
|
|
125
|
+
declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
126
|
+
type FetchImpl = (input: string, init: {
|
|
127
|
+
method: string;
|
|
128
|
+
headers: Record<string, string>;
|
|
129
|
+
body: string;
|
|
130
|
+
signal: AbortSignal;
|
|
131
|
+
}) => Promise<{
|
|
132
|
+
ok: boolean;
|
|
133
|
+
status: number;
|
|
134
|
+
text(): Promise<string>;
|
|
135
|
+
}>;
|
|
136
|
+
/** Single HTTP attempt classified to an `AckResult` shape (without nextRetryAt). */
|
|
137
|
+
type AttemptOutcome = {
|
|
138
|
+
success: true;
|
|
139
|
+
httpStatus: number;
|
|
140
|
+
responseBody?: string;
|
|
141
|
+
durationMs: number;
|
|
142
|
+
} | {
|
|
143
|
+
success: false;
|
|
144
|
+
retriable: boolean;
|
|
145
|
+
httpStatus?: number;
|
|
146
|
+
responseBody?: string;
|
|
147
|
+
error?: string;
|
|
148
|
+
durationMs: number;
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Send one HTTP attempt for the delivery. Pure (no DB writes) so the
|
|
152
|
+
* dispatcher owns retry-schedule + ack logic.
|
|
153
|
+
*
|
|
154
|
+
* - 2xx → success
|
|
155
|
+
* - 4xx (except 408/429) → permanent failure (retriable = false → goes to `dead`)
|
|
156
|
+
* - 408, 429, 5xx, transport → retriable
|
|
157
|
+
*/
|
|
158
|
+
declare function sendOnce(delivery: WebhookDelivery, fetchImpl: FetchImpl): Promise<AttemptOutcome>;
|
|
159
|
+
/**
|
|
160
|
+
* Stripe-style retry schedule. Returns the next `nextRetryAt` ms (relative
|
|
161
|
+
* to `now`) given how many attempts have already happened, or `null` if
|
|
162
|
+
* the row should be moved to `dead`.
|
|
163
|
+
*
|
|
164
|
+
* attempt 1 fails -> retry in ~1s
|
|
165
|
+
* attempt 2 fails -> ~10s
|
|
166
|
+
* attempt 3 fails -> ~1m
|
|
167
|
+
* attempt 4 fails -> ~10m
|
|
168
|
+
* attempt 5 fails -> ~1h
|
|
169
|
+
* attempt 6 fails -> ~6h
|
|
170
|
+
* attempt 7 fails -> ~24h
|
|
171
|
+
* attempt 8+ fails -> dead
|
|
172
|
+
*
|
|
173
|
+
* Each delay is multiplied by jitter ∈ [0.8, 1.2].
|
|
174
|
+
*/
|
|
175
|
+
declare function nextRetryDelayMs(attemptsSoFar: number, rng?: () => number): number | null;
|
|
176
|
+
/**
|
|
177
|
+
* Compose an `AckResult` from an `AttemptOutcome`, applying the retry
|
|
178
|
+
* schedule on retriable failures.
|
|
179
|
+
*/
|
|
180
|
+
declare function classifyAttempt(outcome: AttemptOutcome, attemptsSoFar: number, now?: number, rng?: () => number): AckResult;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Minimal logger surface — kernel's `Logger` is compatible (extra params
|
|
184
|
+
* accepted). Keeping it permissive avoids a hard dependency on the spec
|
|
185
|
+
* Logger interface here.
|
|
186
|
+
*/
|
|
187
|
+
interface DispatcherLogger {
|
|
188
|
+
warn: (msg: string, meta?: any) => void;
|
|
189
|
+
info?: (msg: string, meta?: any) => void;
|
|
190
|
+
}
|
|
191
|
+
interface DispatcherOptions {
|
|
192
|
+
/** Stable id identifying this dispatcher node. */
|
|
193
|
+
nodeId: string;
|
|
194
|
+
/** Cluster service providing `lock` (and optional metrics). */
|
|
195
|
+
cluster: IClusterService;
|
|
196
|
+
/** Outbox backend. */
|
|
197
|
+
outbox: IWebhookOutbox;
|
|
198
|
+
/**
|
|
199
|
+
* How many partitions to split work across. Each tick the dispatcher
|
|
200
|
+
* attempts to acquire each partition's lock independently — the node
|
|
201
|
+
* that wins owns that partition for the duration of the batch.
|
|
202
|
+
*
|
|
203
|
+
* Default: 8 (matches webhook-delivery.mdx §4 example).
|
|
204
|
+
*/
|
|
205
|
+
partitionCount?: number;
|
|
206
|
+
/** Max rows to claim from each partition per tick. Default 32. */
|
|
207
|
+
batchSize?: number;
|
|
208
|
+
/** Tick interval in ms. Default 250. */
|
|
209
|
+
intervalMs?: number;
|
|
210
|
+
/** Per-partition lock TTL. Default = 5 × intervalMs. */
|
|
211
|
+
lockTtlMs?: number;
|
|
212
|
+
/** Visibility timeout for claimed rows. Default = 2 × lockTtlMs. */
|
|
213
|
+
claimTtlMs?: number;
|
|
214
|
+
/** Override `globalThis.fetch` (tests). */
|
|
215
|
+
fetchImpl?: FetchImpl;
|
|
216
|
+
/** Hook fired after every attempt — observability hook. */
|
|
217
|
+
onAttempt?: (delivery: WebhookDelivery, success: boolean) => void;
|
|
218
|
+
/** RNG override for the retry-jitter schedule (tests). */
|
|
219
|
+
rng?: () => number;
|
|
220
|
+
/** Logger callback (optional). */
|
|
221
|
+
logger?: DispatcherLogger;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Cross-node webhook dispatcher.
|
|
225
|
+
*
|
|
226
|
+
* **Design** — each tick the dispatcher iterates over `partitionCount`
|
|
227
|
+
* logical partitions. For each, it tries to acquire a cluster-scoped lock
|
|
228
|
+
* (`webhook.dispatcher.partition.{i}`) with a short TTL. If it wins the
|
|
229
|
+
* lock, it claims up to `batchSize` ready rows whose `hash(webhookId) mod
|
|
230
|
+
* partitionCount === i`, POSTs them, and acks. The lock is released
|
|
231
|
+
* immediately after the batch so other nodes can fairly rotate through.
|
|
232
|
+
*
|
|
233
|
+
* **Why per-partition locks rather than one global lock?**
|
|
234
|
+
*
|
|
235
|
+
* 1. Throughput — N nodes can process N partitions concurrently.
|
|
236
|
+
* 2. Partition affinity — rows for the same webhook always sort into the
|
|
237
|
+
* same partition, preserving in-order delivery per webhook.
|
|
238
|
+
* 3. Failure isolation — a stuck node only blocks its partition until the
|
|
239
|
+
* TTL elapses; other partitions keep moving.
|
|
240
|
+
*
|
|
241
|
+
* **At-least-once, not exactly-once.** Receivers MUST be idempotent on the
|
|
242
|
+
* `X-Objectstack-Delivery` (== row id) header. If the HTTP call succeeds
|
|
243
|
+
* but the ack write fails, the row reverts to pending after the claim TTL
|
|
244
|
+
* and will be re-posted.
|
|
245
|
+
*/
|
|
246
|
+
declare class WebhookDispatcher {
|
|
247
|
+
private readonly opts;
|
|
248
|
+
private timer;
|
|
249
|
+
private running;
|
|
250
|
+
private inflightTick;
|
|
251
|
+
constructor(options: DispatcherOptions);
|
|
252
|
+
/** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
|
|
253
|
+
start(): void;
|
|
254
|
+
/** Stop the loop and wait for the in-flight tick to drain. */
|
|
255
|
+
stop(): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* Run one full tick (all partitions, single attempt each). Exposed for
|
|
258
|
+
* deterministic tests that want to step the dispatcher manually.
|
|
259
|
+
*/
|
|
260
|
+
tick(): Promise<void>;
|
|
261
|
+
private scheduleTick;
|
|
262
|
+
private runTick;
|
|
263
|
+
private runPartition;
|
|
264
|
+
private processRow;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface OptionalLogger {
|
|
268
|
+
info?(msg: string, meta?: unknown): void;
|
|
269
|
+
warn?(msg: string, meta?: unknown): void;
|
|
270
|
+
debug?(msg: string, meta?: unknown): void;
|
|
271
|
+
}
|
|
272
|
+
interface DeliveryRetentionOptions {
|
|
273
|
+
/**
|
|
274
|
+
* Object name backing the outbox. Defaults to `sys_webhook_delivery`.
|
|
275
|
+
*/
|
|
276
|
+
objectName?: string;
|
|
277
|
+
/**
|
|
278
|
+
* How long to keep `success` rows. Default 7 days. Set to `0` to
|
|
279
|
+
* disable the success sweep (keep forever — not recommended in
|
|
280
|
+
* production).
|
|
281
|
+
*/
|
|
282
|
+
successTtlMs?: number;
|
|
283
|
+
/**
|
|
284
|
+
* How long to keep `dead` rows. Default 30 days. Set to `0` to
|
|
285
|
+
* keep forever.
|
|
286
|
+
*/
|
|
287
|
+
deadTtlMs?: number;
|
|
288
|
+
/**
|
|
289
|
+
* How often to run the sweep. Default 1h.
|
|
290
|
+
*/
|
|
291
|
+
sweepIntervalMs?: number;
|
|
292
|
+
logger?: OptionalLogger;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Periodically prunes `sys_webhook_delivery` rows so the table doesn't
|
|
296
|
+
* grow unbounded.
|
|
297
|
+
*
|
|
298
|
+
* Without this every successful POST would leave a permanent row. At
|
|
299
|
+
* even moderate scale (10 events/s × 3 webhooks = 30 rows/s = ~2.6M
|
|
300
|
+
* rows/day) the table becomes a problem within a week.
|
|
301
|
+
*
|
|
302
|
+
* Retention defaults mirror Stripe/GitHub:
|
|
303
|
+
* - `success`: 7 days
|
|
304
|
+
* - `dead`: 30 days (kept longer for audit & manual re-delivery)
|
|
305
|
+
* - `pending`/`in_flight`/`failed`: never auto-pruned (they're
|
|
306
|
+
* either live work or signal something needs human attention)
|
|
307
|
+
*
|
|
308
|
+
* Runs on whichever node holds the sweeper interval — it doesn't need
|
|
309
|
+
* a cluster lock because DELETE WHERE created_at < threshold is
|
|
310
|
+
* idempotent; multiple nodes running concurrently is wasteful but
|
|
311
|
+
* safe.
|
|
312
|
+
*/
|
|
313
|
+
declare class DeliveryRetentionSweeper {
|
|
314
|
+
private readonly engine;
|
|
315
|
+
private readonly objectName;
|
|
316
|
+
private readonly successTtlMs;
|
|
317
|
+
private readonly deadTtlMs;
|
|
318
|
+
private readonly sweepIntervalMs;
|
|
319
|
+
private readonly logger;
|
|
320
|
+
private timer;
|
|
321
|
+
private running;
|
|
322
|
+
constructor(engine: IDataEngine, opts?: DeliveryRetentionOptions);
|
|
323
|
+
start(): void;
|
|
324
|
+
stop(): void;
|
|
325
|
+
/** Run one sweep immediately. Returns the number of rows deleted. */
|
|
326
|
+
sweep(now?: number): Promise<{
|
|
327
|
+
success: number;
|
|
328
|
+
dead: number;
|
|
329
|
+
}>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface WebhookOutboxPluginOptions extends Partial<Omit<DispatcherOptions, 'cluster' | 'outbox' | 'nodeId'>> {
|
|
333
|
+
/**
|
|
334
|
+
* Override the outbox backend. If omitted a fresh `MemoryWebhookOutbox`
|
|
335
|
+
* is used — fine for local development, **not for production**: each
|
|
336
|
+
* node will see only its own rows.
|
|
337
|
+
*
|
|
338
|
+
* Pass a factory if you need the kernel-resolved `IDataEngine`:
|
|
339
|
+
*
|
|
340
|
+
* ```ts
|
|
341
|
+
* outbox: (ctx) => new SqlWebhookOutbox(
|
|
342
|
+
* ctx.getService('objectql'), { partitionCount: 8 },
|
|
343
|
+
* ),
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);
|
|
347
|
+
/**
|
|
348
|
+
* Stable node id. If omitted, uses `process.env.OBJECTSTACK_NODE_ID`
|
|
349
|
+
* or a random UUID generated at plugin init.
|
|
350
|
+
*/
|
|
351
|
+
nodeId?: string;
|
|
352
|
+
/**
|
|
353
|
+
* If `false`, the plugin registers the outbox/dispatcher services but
|
|
354
|
+
* does NOT auto-start the loop — useful for tests that want to step
|
|
355
|
+
* the dispatcher manually via `dispatcher.tick()`.
|
|
356
|
+
*
|
|
357
|
+
* Default: true.
|
|
358
|
+
*/
|
|
359
|
+
autoStart?: boolean;
|
|
360
|
+
/**
|
|
361
|
+
* Auto-enqueue config. When enabled (default `true` if the realtime
|
|
362
|
+
* + data engine services are available), the plugin subscribes to
|
|
363
|
+
* `data.record.*` events emitted by the engine and automatically
|
|
364
|
+
* enqueues a delivery row for every matching `sys_webhook` row.
|
|
365
|
+
*
|
|
366
|
+
* Set `false` to disable and only use the imperative
|
|
367
|
+
* `outbox.enqueue()` API.
|
|
368
|
+
*/
|
|
369
|
+
autoEnqueue?: boolean | AutoEnqueuerOptions;
|
|
370
|
+
/**
|
|
371
|
+
* Retention sweep config. When enabled (default `true` if a SQL
|
|
372
|
+
* outbox is in use), a periodic timer prunes old `success` and
|
|
373
|
+
* `dead` rows from `sys_webhook_delivery`.
|
|
374
|
+
*
|
|
375
|
+
* Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
|
|
376
|
+
*/
|
|
377
|
+
retention?: boolean | DeliveryRetentionOptions;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Wires a persistent, cluster-aware webhook outbox into the kernel.
|
|
381
|
+
*
|
|
382
|
+
* Registered services:
|
|
383
|
+
* - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)
|
|
384
|
+
* - `webhook.dispatcher` → `WebhookDispatcher` (manual `tick()` if needed)
|
|
385
|
+
* - `webhook.autoEnqueuer` → `AutoEnqueuer` when auto-enqueue is on
|
|
386
|
+
* - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on
|
|
387
|
+
*
|
|
388
|
+
* End-to-end flow once auto-enqueue is enabled:
|
|
389
|
+
*
|
|
390
|
+
* engine.insert('contact', {...})
|
|
391
|
+
* → engine publishes data.record.created via IRealtimeService
|
|
392
|
+
* → AutoEnqueuer matches active sys_webhook rows in O(1)
|
|
393
|
+
* → outbox.enqueue() runs fire-and-forget (not on the write path)
|
|
394
|
+
* → dispatcher claims and POSTs (cluster-coordinated)
|
|
395
|
+
*
|
|
396
|
+
* **Cluster requirement** — this plugin depends on the cluster service
|
|
397
|
+
* (`ClusterServicePlugin`). With the default `memory` driver the
|
|
398
|
+
* dispatcher works correctly inside a single process; with a real driver
|
|
399
|
+
* (`@objectstack/service-cluster-redis`) it correctly coordinates work
|
|
400
|
+
* across nodes.
|
|
401
|
+
*/
|
|
402
|
+
declare class WebhookOutboxPlugin implements Plugin {
|
|
403
|
+
private readonly options;
|
|
404
|
+
name: string;
|
|
405
|
+
version: string;
|
|
406
|
+
type: "standard";
|
|
407
|
+
dependencies: string[];
|
|
408
|
+
private dispatcher;
|
|
409
|
+
private autoEnqueuer;
|
|
410
|
+
private retention;
|
|
411
|
+
private outboxInstance;
|
|
412
|
+
constructor(options?: WebhookOutboxPluginOptions);
|
|
413
|
+
init(ctx: PluginContext): Promise<void>;
|
|
414
|
+
dispose(): Promise<void>;
|
|
415
|
+
private resolveOutbox;
|
|
416
|
+
private bootAutoEnqueue;
|
|
417
|
+
private bootRetention;
|
|
418
|
+
private tryGetService;
|
|
419
|
+
/**
|
|
420
|
+
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
|
|
421
|
+
* is available. Silently no-ops in environments without an HTTP
|
|
422
|
+
* server (MSW, edge tests, pure library use). Auth is delegated to
|
|
423
|
+
* the better-auth session cookie — every authenticated user counts.
|
|
424
|
+
*/
|
|
425
|
+
private registerAdminRoutes;
|
|
426
|
+
/**
|
|
427
|
+
* Resolve the requesting user's id from a better-auth session cookie.
|
|
428
|
+
* Returns `undefined` for anonymous callers — the caller decides
|
|
429
|
+
* whether that's a 401.
|
|
430
|
+
*/
|
|
431
|
+
private resolveSessionUserId;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* In-memory `IWebhookOutbox` for tests and single-process development.
|
|
436
|
+
*
|
|
437
|
+
* Implements the atomic-claim semantics by running its claim/ack logic
|
|
438
|
+
* synchronously (single-threaded JS event loop) inside one `Map`. Two
|
|
439
|
+
* `MemoryWebhookOutbox` instances do NOT share state — for the cross-node
|
|
440
|
+
* test the *same* instance is passed to both dispatchers (simulating one
|
|
441
|
+
* shared database).
|
|
442
|
+
*
|
|
443
|
+
* A production SQL-backed implementation will live in a sibling file and
|
|
444
|
+
* use `SELECT ... FOR UPDATE SKIP LOCKED`.
|
|
445
|
+
*/
|
|
446
|
+
declare class MemoryWebhookOutbox implements IWebhookOutbox {
|
|
447
|
+
private readonly rows;
|
|
448
|
+
/** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
|
|
449
|
+
private readonly dedup;
|
|
450
|
+
enqueue(input: EnqueueInput): Promise<string>;
|
|
451
|
+
claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
|
|
452
|
+
ack(id: string, result: AckResult): Promise<void>;
|
|
453
|
+
list(filter?: {
|
|
454
|
+
status?: DeliveryStatus;
|
|
455
|
+
}): Promise<WebhookDelivery[]>;
|
|
456
|
+
redeliver(id: string): Promise<WebhookDelivery>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Stable, framework-free partition hash. The dispatcher uses this to
|
|
461
|
+
* assign webhooks to partitions; the in-memory outbox uses the same hash
|
|
462
|
+
* to filter rows in `claim()`. Both call sites MUST agree, which is why
|
|
463
|
+
* this is a single shared helper.
|
|
464
|
+
*
|
|
465
|
+
* Uses a 32-bit FNV-1a variant — fast, no allocations, deterministic.
|
|
466
|
+
*/
|
|
467
|
+
declare function hashPartition(key: string, count: number): number;
|
|
468
|
+
|
|
469
|
+
export { AckResult, type AttemptOutcome, AutoEnqueuer, type AutoEnqueuerOptions, ClaimOptions, DEFAULT_TIMEOUT_MS, type DeliveryRetentionOptions, DeliveryRetentionSweeper, DeliveryStatus, type DispatcherOptions, EnqueueInput, type FetchImpl, IWebhookOutbox, MemoryWebhookOutbox, WebhookDelivery, WebhookDispatcher, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, classifyAttempt, hashPartition, nextRetryDelayMs, sendOnce };
|