feat(eventing): add typed signal-to-event projection architecture - #363
feat(eventing): add typed signal-to-event projection architecture#363robbiemu wants to merge 35 commits into
Conversation
6fb2377 to
2f5ac1c
Compare
2f5ac1c to
0212b99
Compare
# Conflicts: # apps/api/src/services/alerts/AlertsService.ts # apps/api/src/services/integrations/planetscale/PlanetScaleWebhookQueue.ts # apps/cli/src/server/serve.ts # apps/cli/test/server-network.test.ts
|
I now have a working, mostly tested and verified version of this. Just putting a final review / finishing touches on it |
…rting-core # Conflicts: # apps/api/src/services/alerts/AlertsService.ts # apps/cli/src/server/checkpoints.ts # apps/cli/src/server/serve.ts # apps/cli/test/server-network.test.ts
…rting-core # Conflicts: # apps/api/src/services/alerts/AlertsService.ts
|
This is pretty cool and I think this moves alerts into a cool direction and agree with most patterns you build here already tbh! This is quite a massive PR and from looking through it there are quite a lot of small nitpicks/patterns etc. Anyways this looks great and want to get this in asap |
Yes some architectural changes can grow into a bit of a problem, we had to move where and how signals are made into events. One thing we could do if you are not wanting to go through so much manually is to pick archetypical examples to call out, then I can take that input to guide my agent to find and repair similar issues throughout. Not to say I object to you just doing it; it might be the most direct way to get what you want. I'm just happy i don't need to maintain a parallel fork! Happy to get this in. |
|
Went through the whole thing properly now (had a worktree with the PR head, typecheck + all the new tests are green btw, lint isnt). Still think the direction is right and want this in, but there is one systemic thing and a couple of real bugs that need to happen before merge. Ill try to give archetypes like you suggested so you can let your agent sweep the rest. 1. never try/catch, no plain Error, no instanceof — this is the big oneThe whole PR is written in throw land. Across the non-test diff its ~143
How this should look, using your own code as the examples: errors are // packages/eventing-core/src/predicate.ts:28 — today
export class SignalPredicateValidationError extends Error { ... }
// instead
export class SignalPredicateInvalid extends Schema.TaggedError<SignalPredicateInvalid>()(
"@maple/eventing-core/SignalPredicateInvalid",
{
message: Schema.String,
issues: Schema.Array(Schema.Struct({ path: Schema.String, message: Schema.String })),
},
) {}// apps/cli/src/server/eventing/control-store.ts:321 — today
export class EventConsumerInputError extends Error {}
export class EventConsumerNotFoundError extends Error {}
export class EventConsumerConflictError extends Error {}
// instead (and give them the fields, thats the whole point)
export class EventConsumerNotFound extends Schema.TaggedError<EventConsumerNotFound>()(
"@maple/cli/eventing/EventConsumerNotFound",
{ message: Schema.String, consumerId: Schema.String },
) {}
export class EventConsumerLeaseConflict extends Schema.TaggedError<EventConsumerLeaseConflict>()(
"@maple/cli/eventing/EventConsumerLeaseConflict",
{ message: Schema.String, consumerId: Schema.String, leaseId: Schema.String, expiresAtMs: Schema.Number },
) {}validation is a schema, not a chain of throws. dont catch to return null/false, use the primitive. // predicate.ts:107 — today
const parseInt64 = (value: string): bigint | null => {
try { const parsed = BigInt(value); return ... } catch { return null }
}
// instead: Schema.BigInt (or BigIntFromString) + a check for the int64 range, then
Schema.decodeUnknownOption(Int64FromString)(value)// PlanetScaleWebhookQueue.ts:40 — today: try/catch inside Schema.makeFilter
// instead: planetScaleWebhookPayloadFromEvent returns Result/Option and the filter is
Schema.makeFilter((job) => !("event" in job) || Result.isSuccess(payloadFromEvent(job)), ...)never instanceof / regex on message to decide what an error is. Effect.catchTags({
"@maple/cli/eventing/EventConsumerNotFound": (e) => Effect.succeed(text(e.message, 404)),
"@maple/cli/eventing/EventConsumerLeaseConflict": (e) => Effect.succeed(text(e.message, 409)),
})and the metric outcome is just request bodies are casts are a hack imo. 13 inline Repo has the lint for this btw, 2. blockers (actual bugs)
3. other stuff
4. behaviour changes I want to be explicit aboutDid a separate pass on what this changes for stuff thats already running, since a lot of it isnt in the PR body. Some of these are fine, some I want to decide on explicitly:
Happy to pair on any of this, and once the error model is flipped ill go through it again quickly. |
Makisuo
left a comment
There was a problem hiding this comment.
inline version of the comment above so the agent has anchors, top comment has the archetypes and examples
Address PR 363 review across typed projection validation, shared alert lifecycle logic, compatible PlanetScale delivery and receipt retention, and the initial public control schema. Keep warehouse ingestion available at outbox capacity, expose explicit delivery-gap recovery and abandonment, and capture consistent checkpoint state before asynchronous archive writes. Verified 42 core, 110 CLI, and 187 API tests; core/API/CLI types; Effect lint; generated schemas and migration identities. Installation and development-schema conversion remain for a separate apply phase.
|
Review pass is in The systemic Effect work is done across the new eventing surfaces: tagged errors, schema/Result/Effect decoding, bounded predicates at the root schema, and removal of the reviewed casts, non-null assertions, and catch-based control flow. Alert lifecycle now shares one hysteresis implementation and domain types. PlanetScale keeps its old timestamp fallback and inline ignore/log behavior, legacy queue jobs remain readable, poison redeliveries are skipped, sibling queue messages are isolated, and receipts now have indexed retention. The migration metadata was regenerated and the rollout dependency is documented. Local eventing now starts at control schema v1 under the normal schema gate. Outbox accounting is constant-time; capacity loss no longer drops warehouse rows and is exposed as a durable delivery gap with explicit recovery. HTTP/OTLP decoding, metrics, token helpers, CORS, checkpoint compatibility, startup/shutdown handling, knip, schemas, and real SQLite coverage were also tightened. I kept the checkpoint capture atomic rather than allowing control state to get ahead of warehouse state, but moved the asynchronous file write outside the admission gate and documented the remaining native-backup availability cost. Validation is green: 579 CLI tests, 460 API tests, eventing/alerting core tests, relevant typechecks, Effect lint, generated schemas, and migration/schema-control checks. I can't believe Astra did nearly all of that in just one commit. Sorry, I will be more deliberate in my prompting next time. |
Describe the initial control schema v1 and supported upstream checkpoint and queue formats. Remove development-build migration instructions and transitional queue claims; align timestamp fallback, inline acknowledgements, and outbox-capacity documentation with the implementation.
Remove the stale maintenance-token header expectation left after the runtime policy correction. All 12 server-network tests pass.
Summary
This PR introduces a host-neutral typed signal-to-event projection architecture for Maple’s hosted and Local runtimes, and adds a durable named-consumer boundary to Maple Local.
It separates three concerns:
It also extracts the scheduled-alert decision and delivery policy into a reusable host-neutral package while preserving existing alert behavior.
Related to #222.
Event paths
Immediate per-occurrence path
authenticated input → source adapter → typed normalized signal → bounded selector → pure projector → durable outbox
The original telemetry continues through the existing warehouse encoder. A matched event is staged before the warehouse write and marked ready only after that write succeeds. Retrying the same source occurrence recomputes the same event identity.
Scheduled aggregate path
warehouse query → observation → alert lifecycle evaluation → factual alert event → existing delivery outbox
Rates, thresholds, percentiles, absence, recovery, flap suppression, and renotification remain scheduled conclusions over a window. They are not modeled as individual ingest-time facts.
Core architecture
A source definition publishes a typed field catalog, including allowed operators, sensitivity, and replay capability. Projection configuration stores a bounded typed predicate AST.
Projection revisions compile into immutable registry snapshots only after source fields, operators, activation time, and closed projector configuration are validated. Evaluation runs every matching projection from one snapshot and isolates failures so one malformed projector does not suppress successful siblings.
Projectors are pure, versioned functions. They declare an ID/version, accepted source kinds, output type/schema, and closed configuration decoder. They perform no I/O or external side effects.
Canonical CloudEvents and identity
Projected events use a common versioned CloudEvents envelope.
Event IDs are SHA-256 hashes over a length-delimited tuple of tenant, source kind, source, source occurrence ID, projection ID, and projection revision. Two optional backward-compatible extensions expose source occurrence identity and its quality. Historical envelopes without those extensions remain valid.
This lets downstream consumers correlate source occurrence → immutable Maple event → deterministic transport transaction without parsing event data.
Durable Local outbox and consumers
Maple Local stores projection revisions, active pointers, bounded failures, staged/ready events, and consumer state in a private SQLite control database.
Named consumers support:
Staged events are never pruned. Ready ordering is stable across restart and schema migration. Checkpoint manifests bind the control snapshot alongside the existing data backup.
Alert-core extraction
The new alerting-core package owns host-neutral observation evaluation, trigger/resolve/renotify planning, flap suppression, no-data recovery safety, scheduling helpers, delivery idempotency, and bounded retry policy.
Existing alert queries, persistence, queue behavior, and delivery payloads remain compatible. The factual event envelope is additive.
Existing producer convergence
The existing verified provider-webhook path now creates its factual event through the common projection seam while retaining queue compatibility, including jobs queued before deployment.
This demonstrates the architecture without making any provider-specific vocabulary part of the projection core.
Safety and boundedness
The implementation enforces:
Deliberate boundaries
This PR does not:
Provider adapters, deployment policy, transport delivery, and live credentials remain separate integrations built on the generic contracts introduced here.
Review guide
Primary surfaces:
packages/eventing-core: typed model, predicates, source/projector registries, deterministic identity, schemas, and fixtures;packages/alerting-core: alert evaluation, lifecycle planning, idempotency, scheduling, and retry policy;apps/cli/src/server/eventing: source-neutral normalization, telemetry, runtime, SQLite state, outbox, and consumer protocol;apps/cli/src/server/serve.ts: decode-once integration and authenticated control/consumer endpoints;apps/cli/src/server/checkpoints.ts: eventing-control checkpoint participation;docs/signal-to-event-projection.mdanddocs/local-event-consumers.md; anddocs/eventing-extension-guide.md: a complete compile-time source adapter and projector walkthrough with host wiring, versioning, testing, and review checklists.Review status
Ready for review. Current upstream
mainis merged into the branch, and GitHub reports it mergeable.Validation
Against the clean provider-neutral tree:
main, 146 host-neutral core/Local tests and 103 hosted API tests were rerun successfully;main; andgit diff --checkpasses.Review change ledger
instanceofpaths with tagged errors and schema/Result/Effect decoding; predicate depth and total-node bounds now live in the root schema.skipped, and added receipt retention/indexing. Migration metadata was regenerated and rollout ordering is explicit.