feat(v1.1.5): pubsub::publish_durable SDK + pubsub:* triggers

Durable pub/sub through the universal outbox — the sixth trigger kind.

- `pubsub::publish_durable(topic, message)` Rhai SDK (no handle; topics
  ARE the grouping unit). Message JSON-encoded; Blobs base64 at any
  depth.
- `PubsubService` trait in picloud-shared with the topic matcher +
  validator (exact / `<prefix>.*` / `*`; mid-pattern wildcards
  rejected). `PostgresPubsubRepo` + `PubsubServiceImpl` in manager-core.
- Publish-time fan-out: one outbox row per matching enabled pubsub
  trigger, all in ONE transaction (no half-fan-out on crash). No
  matching trigger → publish succeeds silently, zero rows.
- `pubsub:*` trigger kind via Layout-E (0020: widen both CHECKs +
  pubsub_trigger_details + partial index), TriggerEvent::Pubsub +
  ctx.event.pubsub, dispatcher arm, admin endpoint POST /triggers/pubsub
  (validates topic pattern + reuses validate_trigger_target).
- AppPubsubPublish capability → script:write (seven-scope held).
- Dashboard Pub/Sub trigger form on the Triggers tab + list rendering.

publish_ephemeral stays deferred to v1.2. ~18 new tests (service
in-memory incl. transactional-rollback, shared matcher, bridge
encoding). No DB required for the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-03 21:37:06 +02:00
parent 6e132b6ee0
commit 834c787ee1
25 changed files with 1240 additions and 16 deletions

View File

@@ -261,6 +261,15 @@ export interface CreateCronTriggerInput {
retry_base_ms?: number;
}
export interface CreatePubsubTriggerInput {
script_id: string;
topic_pattern: string;
dispatch_mode?: TriggerDispatchMode;
retry_max_attempts?: number;
retry_backoff?: 'exponential' | 'linear' | 'constant';
retry_base_ms?: number;
}
export interface ExecutionResult {
status: number;
headers: Record<string, string>;
@@ -632,6 +641,11 @@ export const api = {
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/cron`,
{ method: 'POST', body: JSON.stringify(input) }
),
createPubsub: (idOrSlug: string, input: CreatePubsubTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/pubsub`,
{ method: 'POST', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, triggerId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,

View File

@@ -118,6 +118,11 @@
let createCronTimezone = $state('UTC');
let creatingCron = $state(false);
let createCronError = $state<string | null>(null);
// Pub/Sub triggers (v1.1.5).
let createPubsubScriptId = $state('');
let createPubsubTopic = $state('');
let creatingPubsub = $state(false);
let createPubsubError = $state<string | null>(null);
let triggerToRemove = $state<Trigger | null>(null);
let removingTrigger = $state(false);
// Endpoint scripts only — modules can't be trigger targets.
@@ -153,6 +158,27 @@
}
}
async function submitCreatePubsub(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingPubsub = true;
createPubsubError = null;
try {
await api.triggers.createPubsub(app.id, {
script_id: createPubsubScriptId,
topic_pattern: createPubsubTopic.trim()
});
createPubsubScriptId = '';
createPubsubTopic = '';
await loadTriggers(app.id);
} catch (err) {
createPubsubError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingPubsub = false;
}
}
async function confirmRemoveTrigger() {
if (!app || !triggerToRemove) return;
removingTrigger = true;
@@ -843,6 +869,42 @@
</div>
</form>
<h2>Pub/Sub triggers</h2>
<p class="muted">
Subscribe an endpoint script to durable pub/sub messages. Topic
patterns are an exact topic (<code>user.created</code>), a prefix
wildcard (<code>user.*</code>), or <code>*</code> for every topic.
</p>
<form class="create-form" onsubmit={submitCreatePubsub}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createPubsubScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label>
<span>Topic pattern</span>
<input bind:value={createPubsubTopic} required placeholder="user.*" />
</label>
</div>
{#if createPubsubError}
<div class="error">{createPubsubError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={creatingPubsub || !createPubsubScriptId || !createPubsubTopic.trim()}
>
{creatingPubsub ? 'Creating…' : 'Create pub/sub trigger'}
</button>
</div>
</form>
{#if triggers.length === 0}
<p class="muted">No triggers in this app yet.</p>
{:else}
@@ -857,9 +919,11 @@
<span class="muted small">
last fired: {t.details.last_fired_at ?? 'never'}
</span>
{:else if t.details.kind === 'kv' || t.details.kind === 'docs'}
{:else if t.details.kind === 'kv' || t.details.kind === 'docs' || t.details.kind === 'files'}
<code>{t.details.collection_glob}</code>
<span class="muted">{t.details.ops.join(', ') || 'any op'}</span>
{:else if t.details.kind === 'pubsub'}
<code>{t.details.topic_pattern}</code>
{/if}
<span class="muted small">{t.script_id}</span>
</div>