@shware/workflow 9.0.2 → 9.1.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/README.md +74 -4
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +7 -7
- package/dist/cloudflare/bindings.d.mts +7 -7
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/dsl/flow.cjs +2 -1
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +2 -1
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/engine/interpreter.cjs +46 -10
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.mjs +46 -10
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +31 -6
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +31 -6
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/ingest.cjs +86 -3
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +30 -3
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +30 -3
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +85 -4
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/ir.cjs +2 -1
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +6 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +6 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -1
- package/dist/ir.mjs.map +1 -1
- package/dist/reporting/observer.cjs +2 -0
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.mjs +2 -0
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +28 -0
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +28 -0
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +9 -0
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +2 -0
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +2 -0
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +9 -0
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +14 -0
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +3 -1
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +3 -1
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +14 -0
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +1 -0
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +1 -0
- package/dist/store/tables.mjs.map +1 -1
- package/dist/templates.cjs.map +1 -1
- package/dist/templates.d.cts +20 -0
- package/dist/templates.d.cts.map +1 -1
- package/dist/templates.d.mts +20 -0
- package/dist/templates.d.mts.map +1 -1
- package/dist/templates.mjs.map +1 -1
- package/dist/transports/cloudflare-email.cjs.map +1 -1
- package/dist/transports/cloudflare-email.mjs.map +1 -1
- package/package.json +1 -1
package/dist/store/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/store/index.ts"],"sourcesContent":["import type { PushDevice } from '../engine/ports';\nimport type { BundleIR, ConditionIR, ScalarIR, WorkflowIR } from '../ir';\n\n/**\n * The journey engine's data plane. Postgres is the store (`PostgresJourneyStore`); this\n * interface is the port the host, the runner and the fact source use to persist or look\n * anything up, and what a test stub implements in its place. Execution (durable steps,\n * sleeps, wake-ups) is the workflow runtime's job and stays out of here.\n *\n * Timestamps are epoch milliseconds throughout; a store may persist them in\n * its native type as long as `sinceMs` comparisons behave.\n */\n\n/**\n * A profile patch or a stored profile. On the way in, `null` means \"remove\n * this property\" (RFC 7386 merge-patch, the only way the API has to unset\n * one); a stored profile never contains null.\n */\nexport type ProfileProps = Record<string, ScalarIR | null | undefined>;\n\nexport interface EventInput {\n /** The occurrence's identity, chosen by the sender; a second insert with the same id is a no-op. */\n id: string;\n userId: string;\n event: string;\n ts: number;\n payload: Record<string, unknown>;\n}\n\nexport interface TriggerRoute {\n workflow: string;\n hash: string;\n /** Payload gate (payload-only condition tree), or null when the trigger has none. */\n where: ConditionIR | null;\n /** Profile gate, or null. */\n filter: ConditionIR | null;\n /** Whether a visitor not signed in may enter. */\n visitors: boolean;\n /**\n * The code version that applied these routes; absent after a hand-posted `POST /deploy`. Read\n * in the same row as the hash, so the pair is always one deploy's.\n */\n version?: string | undefined;\n}\n\nexport interface SegmentTriggerRoute {\n workflow: string;\n hash: string;\n segment: string;\n /** As `TriggerRoute.version`. */\n version?: string | undefined;\n}\n\n/**\n * A Worker version, as Cloudflare's version metadata binding names it: the bundle compiled into\n * that version goes live only over an older one (`replaceRoutesIfNewer`).\n */\nexport interface BundleVersion {\n id: string;\n /** When the version was created (epoch ms): later versions are newer. */\n createdAt: number;\n}\n\nexport interface EntryInput {\n workflow: string;\n userId: string;\n instanceId: string;\n hash: string;\n ts: number;\n /**\n * The instance's start, recorded in the same transaction as the entry — for a runtime whose\n * start is an asynchronous submit (AWS) that may have to be retried.\n */\n execution?: ExecutionStart | undefined;\n}\n\n/** How an instance is started on a runtime that submits it: replayed as is on every retry. */\nexport interface ExecutionStart {\n /** The runtime's idempotency key (the durable execution name). */\n name: string;\n /** What is invoked: a qualified function ARN, its version included. */\n target: string;\n /** The exact payload: a retried submit must send the same bytes, or the runtime refuses it. */\n input: string;\n}\n\nexport interface ExecutionRecord extends ExecutionStart {\n instanceId: string;\n workflow: string;\n userId: string;\n createdAt: number;\n submittedAt?: number | undefined;\n arn?: string | undefined;\n unresolvedAt?: number | undefined;\n callbackId?: string | undefined;\n /** Bumped by every wait the instance arms: a wake or a cleanup of an older one leaves it alone. */\n generation: number;\n deadlineAt?: number | undefined;\n checkedAt?: number | undefined;\n stoppedAt?: number | undefined;\n}\n\n/**\n * What a runtime that submits its starts and parks waits on one-shot callbacks (AWS) keeps\n * beside the entry ledger. The runtime holds the execution history; this holds only the\n * mapping from the engine's instance id to it.\n */\nexport interface ExecutionRegistry {\n get(instanceId: string): Promise<ExecutionRecord | undefined>;\n /**\n * Starts not yet confirmed and not given up on, oldest first: of one (workflow, user) entry\n * still running, or every one created before `createdBefore`.\n */\n pendingStarts(filter: {\n workflow?: string;\n userId?: string;\n createdBefore?: number;\n limit: number;\n }): Promise<ExecutionRecord[]>;\n markSubmitted(instanceId: string, arn: string | undefined, at: number): Promise<void>;\n markUnresolved(instanceId: string, at: number): Promise<void>;\n setArn(instanceId: string, arn: string): Promise<void>;\n /** Record the callback a wait parks on; false when a newer generation is already armed. */\n armCallback(\n instanceId: string,\n callback: { id: string; generation: number; deadlineAt?: number | undefined }\n ): Promise<boolean>;\n /** Forget the callback — only if it is still that generation's. */\n clearCallback(instanceId: string, generation: number): Promise<void>;\n /** Submitted executions whose entry is still running, least recently checked first. */\n toReconcile(limit: number, checkedBefore: number): Promise<ExecutionRecord[]>;\n markChecked(instanceId: string, at: number): Promise<void>;\n /** Record the first stop; a later one keeps it. */\n markStopped(instanceId: string, at: number): Promise<void>;\n}\n\n/**\n * How an entry attempt ended: a fresh row, a 'failed' row of a dead instance\n * taken over (the once policy does not bar a user over one outage), or\n * refused because the pair already entered and is still alive.\n */\nexport type EntryOutcome = 'entered' | 'reclaimed' | null;\n\n/** A device registered for pushes: its provider token belongs to the user who registered it last. */\nexport interface DeviceInput {\n deviceId?: string | undefined;\n /** Null until the device's visitor signs in. */\n userId: string | null;\n visitorId?: string | undefined;\n token: string;\n /** `ios` (APNs) or `android` (FCM): which provider the token belongs to. */\n platform: string;\n at: number;\n}\n\n/** What linking a visitor to a user did; null when it was already linked, or to another user. */\nexport interface VisitorMerge {\n /** Instances dropped because the user is already in the same workflow: to stop. */\n dropped: string[];\n /** The event names and profile properties the user gained: their waits must wake. */\n events: string[];\n props: string[];\n}\n\n/** An instance of a user still in the ledger, and whether its runner was admitted (see `admitInstance`). */\nexport interface RunningInstance {\n instanceId: string;\n /** False until the runner admitted itself: nothing has run, and the tombstone keeps it that way. */\n admitted: boolean;\n}\n\n/** One (workflow, user) entry: the attempt it records and how far that attempt got. */\nexport interface EntryState {\n instanceId: string;\n /** 'running' until the run finalizes it ('completed', 'goal', 'failed', …). */\n status: string;\n /** The runner admitted itself (`admitInstance`): the instance did reach the runtime. */\n admitted: boolean;\n}\n\n/** An entry still waiting for its first admission (`unadmittedEntries`). */\nexport interface UnadmittedEntry {\n instanceId: string;\n workflow: string;\n userId: string;\n}\n\n/** A store plus how to release what opening it acquired (a connection, say). */\nexport interface StoreLease {\n store: JourneyStore;\n close?: () => Promise<void>;\n}\n\nimport type { ErasureState } from './erasure';\n\nexport { type ErasureState, UserErasedError } from './erasure';\nexport { instanceOwner, isVisitorSubject, visitorIdOf, visitorSubject } from './identity';\n\n/**\n * The live database lacks tables or columns the engine reads (an SDK upgrade added them, or the\n * host never ran the migration). Raised before a deploy writes anything.\n */\nexport class SchemaMismatchError extends Error {\n override readonly name = 'SchemaMismatchError';\n\n constructor(readonly problems: readonly string[]) {\n super(\n `the database does not match @shware/workflow's tables — regenerate the drizzle schema (workflow-ui generate) and run your migrations:\\n ${problems.join('\\n ')}`\n );\n }\n}\n\n/** A message copy as reporting records it, before it goes out. */\nexport interface MessageRecord {\n idempotencyKey: string;\n workflow: string;\n instanceId: string;\n nodeId: string;\n template: string;\n /** Where the copy lands: `email`, `push_ios`, `push_android`. */\n channel: string;\n userId: string;\n /** An email's subscription topic; null for a transactional email and for every other channel. */\n topic: string | null;\n sentAt: number;\n}\n\n/** What is known of a copy without its engagement: whose it is, where it went, its topic. */\nexport interface MessageSummary {\n userId: string;\n channel: string;\n topic: string | null;\n}\n\n/** One opt-out to set or clear: a topic of the channel, or the whole channel (`topic: null`). */\nexport interface SuppressionChange {\n topic: string | null;\n suppressed: boolean;\n}\n\n/** What a click is judged by. */\nexport interface ClickContext {\n url: string;\n deliveredAt: number;\n linkCount: number;\n /** The copy's clicks in the window before this one. */\n recent: { position: number; at: number }[];\n}\n\n/** An open or a click, with the verdict on who made it and where it came from. */\nexport interface Engagement {\n at: number;\n machine: boolean;\n /** The rule that called it a machine; null for a person. */\n reason: string | null;\n userAgent: string | null;\n /** The network the request came from (Cloudflare's `cf.asn`, `cf.asOrganization`, `cf.country`), never the address. */\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\n/**\n * Journey reporting: where each instance stands, every copy and how it was engaged with, segment\n * sizes. The store exposes it as `reporting`; the host records it and serves email tracking.\n * Every write is idempotent — a retried step reports again.\n */\n/** Bounds on when an event happened, both inclusive (epoch ms); an absent side is open. */\nexport interface EventWindow {\n sinceMs?: number;\n untilMs?: number;\n}\n\n/**\n * Where a page of events ended: the last row's timestamp and id, the keyset `(ts, id)` the next\n * page starts after. Opaque to callers; `ts` is the store's own rendering of the timestamp, so a\n * round trip loses no precision.\n */\nexport interface EventCursor {\n ts: string;\n id: string;\n}\n\nexport interface JourneyReporting {\n /** The instance entered: its row exists from here, so one a guard ends at once still counts. */\n instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void>;\n nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void>;\n instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void>;\n /** The copy's row id; the row a retry already wrote, when there is one. */\n recordMessage(message: MessageRecord): Promise<string>;\n /**\n * Whether the copy was settled (`settleMessage`: delivered, or settled undelivered) — read from\n * the database itself, so a replayed send step never trusts a cache that missed the settle.\n */\n messageSettled(idempotencyKey: string): Promise<boolean>;\n settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void>;\n /** An email's tracked links, in document order. */\n recordLinks(messageId: string, urls: readonly string[]): Promise<void>;\n /**\n * Record an open of the copy, judged by `judge` against the copy (its recipient and when it was\n * delivered — when it was sent, before the transport answered); `undefined` from the judge\n * records nothing. Read and written in one transaction. False for an unknown copy.\n */\n recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean>;\n /**\n * Record a click on the copy's link at `position`, judged by `judge` against the link, how\n * many links the copy has and its clicks of the last `windowMs`. The copy is locked for the\n * transaction, so clicks arriving together (a scanner following every link at once) are\n * judged one after another, each seeing the ones before. When the judge asks to `backdate`,\n * the window's earlier clicks are reclassified with this one's verdict. A click implies an\n * open. Undefined for an unknown link.\n */\n recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined>;\n /**\n * The email copy's recipient opted out: of the copy's topic, or with `scope: 'all'` of every\n * email. Recorded on the copy and as a suppression (the topic's row, or the channel-wide\n * null-topic row), in one transaction, which also yields who the recipient is and the topic —\n * so the caller acts on the unsubscribe without a second read. A transactional copy\n * (`topic: null`) has nothing to opt out of: nothing is recorded, and the answer says so.\n * Undefined for an unknown copy.\n */\n recordUnsubscribe(\n messageId: string,\n at: number,\n scope?: 'topic' | 'all'\n ): Promise<{ userId: string; topic: string | null } | undefined>;\n /** The copy's owner, channel and topic; undefined for an unknown copy. */\n lookupMessage(messageId: string): Promise<MessageSummary | undefined>;\n /** A push copy the device reported it received. False for an unknown copy. */\n recordDeviceDelivered(messageId: string, at: number): Promise<boolean>;\n /** Drop members of segments no longer deployed, then record every deployed segment's size. */\n snapshotSegments(at: number): Promise<{ segment: string; size: number }[]>;\n}\n\n/** A user the segment sweep has to evaluate, and for which segments. */\nexport interface SegmentCandidate {\n userId: string;\n segments: string[];\n}\n\nexport interface JourneyStore {\n /** Where instances stand, every copy and its engagement, segment sizes. */\n readonly reporting: JourneyReporting;\n\n /**\n * Record that the user's data must go (a deleted account): the tombstone every write about\n * the user is conditional on, from here on and for good. Committed before anything is\n * stopped or erased, so an entry that got in before it is found by the erasure and none gets\n * in after it. Idempotent: a second request, or one for a user already erased, changes nothing.\n */\n requestErasure(userId: string, at: number): Promise<void>;\n /** Users whose erasure was requested and not carried out yet (`'erasing'`), oldest first. */\n pendingErasures(): Promise<string[]>;\n /**\n * Where the user stands: `'none'` (no tombstone), `'erasing'` (requested, their instances\n * being stopped, data still there) or `'erased'` (`forgetUser` went through). Both\n * tombstone states refuse every write about the user; the entry points read this for a fast\n * answer, and the store's conditional writes are the gate itself.\n */\n erasureState(userId: string): Promise<ErasureState>;\n /** The user's journey instances still running, by the entry ledger. */\n runningInstances(userId: string): Promise<RunningInstance[]>;\n /**\n * The runner's admission, before its first observable effect: records that the instance runs\n * (`admitted_at`, kept on replay) — unless the user has an erasure tombstone, in which case\n * nothing is recorded and the runner must not run. Mutually exclusive with `requestErasure`\n * and `forgetUser` for that user (Postgres: the user's advisory lock), so an erasure either\n * finds the instance admitted and stops it, or commits its tombstone first and the admission\n * is refused — never neither.\n *\n * A first admission needs the entry running. An instance already admitted is admitted again\n * whatever the entry's status — the legitimate replay of an instance whose finalisation was\n * cut short after the entry reached its terminal status, which resumes on its own checkpoint\n * and finishes what is left. Never a new attempt's way in: false for a terminal entry never\n * admitted, for an entry no longer in the ledger, for another user's entry, and for an\n * instance whose entry was reclaimed since (`enterJourney` resets the admission with the\n * instance id, so a superseded attempt cannot pass under the id it lost).\n */\n admitInstance(instanceId: string, userId: string): Promise<boolean>;\n /**\n * Erase everything kept about the user — profile, devices, events, wake subscriptions, segment\n * memberships, entries and reporting — and mark the tombstone erased (`erased_at`), which\n * stays: a write that was in flight when the data went cannot bring the user back, and the\n * same id is never reactivated by ingest — which is also why the suppressions go with the\n * rest: nothing is ever sent to the id again, so no opt-out needs keeping. Run only once no\n * instance of theirs runs any more, or one could write some of it back. Idempotent. Mutually\n * exclusive with every write about the user (the user's advisory lock), so the data is gone\n * when this returns — nothing of theirs lands afterwards.\n */\n forgetUser(userId: string, at: number): Promise<void>;\n\n /**\n * The user opted out of the channel (recorded by an unsubscribe, or set through\n * `/subscriptions`): with a `topic`, an opt-out of that topic or of the whole channel; without\n * one, only the whole channel.\n */\n isSuppressed(userId: string, channel: string, topic?: string): Promise<boolean>;\n /** Every opt-out of the user on the channel: the topics, and `null` for the whole channel. */\n listSuppressions(userId: string, channel: string): Promise<(string | null)[]>;\n /**\n * Set or clear opt-outs, all in one transaction: a suppressed change inserts the row (keeping\n * the time of one already there), an unsuppressed one deletes it. Written whatever the user's\n * erasure state — the caller refuses an erased user first — since an opt-out is never refused.\n */\n updateSuppressions(\n userId: string,\n channel: string,\n changes: readonly SuppressionChange[],\n at: number\n ): Promise<void>;\n\n /**\n * What the live database lacks against the engine's tables (read-only); a deploy refuses to\n * run while it lists anything.\n */\n checkSchema(): Promise<string[]>;\n\n /* ---------------------------------- events --------------------------------- */\n /**\n * Append the occurrence; false when its id was already in the log (nothing written). Throws\n * `UserErasedError` for a user with a tombstone, like every write about a user below.\n */\n insertEvent(input: EventInput): Promise<boolean>;\n /**\n * Occurrences of `event` for the user, optionally only those within the window. With\n * `atLeast`, counting may stop there: the result is exact below it and some number ≥ it\n * otherwise — all a `performed(…, { count })` check needs, without counting a heavy user's\n * whole history.\n */\n countEvents(\n userId: string,\n event: string,\n window?: EventWindow,\n atLeast?: number\n ): Promise<number>;\n /**\n * One page of those occurrences' payloads, oldest first — the caller filters them with the\n * shared evaluator and pages on with `next` only while it still needs matches, so a `where`\n * never loads a user's whole history at once. `next` is absent on the last page.\n */\n pageEventPayloads(\n userId: string,\n event: string,\n window: EventWindow | undefined,\n page: { limit: number; after?: EventCursor | undefined }\n ): Promise<{ payloads: Record<string, unknown>[]; next?: EventCursor | undefined }>;\n\n /* -------------------------------- visitors --------------------------------- */\n /** The user a visitor became, if they did. */\n linkedUser(visitorId: string): Promise<string | undefined>;\n /** The visitor was heard from: who they are now, the user they became or their own subject. */\n touchVisitor(visitorId: string, at: number): Promise<string>;\n /**\n * Forget visitors who never signed in and were last heard from before `before`, with no journey\n * running: up to `limit` of them, leaving no tombstone. Answers how many.\n */\n pruneVisitors(before: number, limit: number): Promise<number>;\n /**\n * The visitor became the user: their events, profile, devices, opt-outs and journeys move to the\n * user, once. A visitor already linked keeps its first user. The user's own entry wins over the\n * visitor's in the same workflow, and the visitor's instance is dropped.\n */\n linkVisitor(visitorId: string, userId: string, at: number): Promise<VisitorMerge | null>;\n /** Who an instance's entry belongs to now. */\n entryOwner(instanceId: string): Promise<string | undefined>;\n\n /* --------------------------------- profiles -------------------------------- */\n getProfile(userId: string): Promise<ProfileProps | undefined>;\n /**\n * Merge-patch `props` into the stored profile (a null removes the property) and return the\n * result, atomically: two concurrent identifies cannot lose each other's keys. `at` is when the\n * values were read (now by default): a property written with a later `at` keeps its value, so\n * a redelivered patch cannot undo a newer one.\n */\n mergeProfile(userId: string, props: ProfileProps, at?: number): Promise<ProfileProps>;\n\n /**\n * Who the segment sweep has to evaluate: for each user the engine knows (a profile, an event or\n * a membership), the segments whose membership may have to change, or whose entry still owes a\n * start. It may name a user whose membership turns out right, since `evaluateCondition` decides;\n * it must not leave out one whose membership is wrong. Users come in ascending order; `after`\n * resumes past the last id of the previous page, and users with nothing to do are left out.\n */\n listSegmentCandidates(page: {\n segments: readonly { id: string; condition: ConditionIR }[];\n now: number;\n after?: string | undefined;\n limit: number;\n }): Promise<SegmentCandidate[]>;\n\n /* ---------------------------------- devices --------------------------------- */\n /** The push devices a user or visitor holds now, most recently registered first. */\n listDevices(subject: string): Promise<PushDevice[]>;\n /**\n * Register a device: one row per token and per device, holding only its current owner. A token\n * registered again moves to whoever registered it last (a phone signed into another account) and\n * to the front of their list; a device's new token replaces its old one. Answers the previous\n * owners, whose device waits must wake.\n */\n upsertDevice(device: DeviceInput): Promise<string[]>;\n /** Forget a token (reported dead by APNs / FCM); the user or visitor it belonged to, if any. */\n removeDevice(token: string): Promise<string | undefined>;\n /**\n * The user signed out on the device: the token is kept, bound to nobody, until the next\n * registration binds it again. Who it belonged to, if anyone.\n */\n unbindDevice(token: string): Promise<string | undefined>;\n\n /* ---------------------------- deployed definitions -------------------------- */\n getSegmentCondition(id: string): Promise<ConditionIR | undefined>;\n /** Every deployed segment with its definition: what the segment sweep evaluates. */\n listSegments(): Promise<{ id: string; condition: ConditionIR }[]>;\n findTriggers(event: string): Promise<TriggerRoute[]>;\n listSegmentTriggers(): Promise<SegmentTriggerRoute[]>;\n putWorkflowIR(workflow: WorkflowIR): Promise<void>;\n getWorkflowIR(contentHash: string): Promise<WorkflowIR | undefined>;\n /**\n * Swap the routing tables (segments, event triggers, segment triggers) for\n * the bundle's — atomically, so a concurrent ingest never sees them half\n * empty. Workflow bodies are stored separately (`putWorkflowIR`) because they\n * are content-addressed and outlive the routes that point at them.\n *\n * With `version` — a forced apply of the code's own bundle (`POST /bundle/deploy`) — the\n * version is recorded too, unconditionally and in the same transaction, so the live version\n * always names the code the routes came from. Without it (a hand-posted `POST /deploy`) the\n * recorded version stays as it was: that bundle holds until a newer code version applies its own.\n */\n replaceRoutes(bundle: BundleIR, version?: BundleVersion): Promise<void>;\n /**\n * The Worker version whose bundle the routes came from, when one put them there (through\n * `replaceRoutesIfNewer`, or a forced `replaceRoutes` with a version). Read from the database\n * itself, not a cache in front of it.\n */\n liveBundleVersion(): Promise<BundleVersion | undefined>;\n /**\n * `replaceRoutes`, only when `version` is newer than the live one, recorded in the same\n * transaction: two versions serving side by side during a rollout cannot leave the older one's\n * routes in force. False, with nothing written, when a version as new or newer is live.\n */\n replaceRoutesIfNewer(bundle: BundleIR, version: BundleVersion): Promise<boolean>;\n\n /* ----------------------------- segment membership --------------------------- */\n isSegmentMember(segment: string, userId: string): Promise<boolean>;\n /** Record the membership, with the workflows its entry has to start (see segment_start). */\n addSegmentMember(\n segment: string,\n userId: string,\n ts: number,\n workflows: readonly string[]\n ): Promise<void>;\n /** The workflows the member's entry has not started yet. */\n pendingSegmentStarts(segment: string, userId: string): Promise<string[]>;\n completeSegmentStart(segment: string, userId: string, workflow: string): Promise<void>;\n /** Drop the membership and any start it still owed. */\n removeSegmentMember(segment: string, userId: string): Promise<void>;\n\n /* ------------------------------- entry ledger ------------------------------- */\n /**\n * Record the entry (the once policy). Atomic under concurrent ingests: of\n * two racing attempts exactly one is 'entered' (or 'reclaimed', when the\n * pair's previous instance is recorded as 'failed') and the other null.\n */\n enterJourney(entry: EntryInput): Promise<EntryOutcome>;\n removeEntry(instanceId: string): Promise<void>;\n setEntryStatus(instanceId: string, status: string): Promise<void>;\n /** The (workflow, user) entry as it stands, read from the database itself; undefined if none. */\n getEntry(workflow: string, userId: string): Promise<EntryState | undefined>;\n /**\n * Running entries no runner has admitted, claimed before `claimedBefore` (epoch ms), oldest\n * first: candidates for a start that was lost between the claim and the platform's create.\n */\n unadmittedEntries(claimedBefore: number, limit: number): Promise<UnadmittedEntry[]>;\n /**\n * Mark a lost start `failed`, so the pair's next trigger reclaims it. Only while the entry is\n * still running and unadmitted under this instance id; false when it moved on meanwhile.\n */\n failUnadmittedEntry(instanceId: string): Promise<boolean>;\n /** Execution starts and wake callbacks, for a runtime that submits its starts (AWS). */\n readonly executions: ExecutionRegistry;\n\n /* ------------------------------ wake subscriptions --------------------------- */\n /** Distinct wake handles registered for (user, event). */\n findWakeHandles(userId: string, event: string): Promise<string[]>;\n /** Register the handle for each event; repeated registrations are idempotent. */\n subscribe(handle: string, userId: string, events: readonly string[], ts: number): Promise<void>;\n /** Drop every registration the handle holds. */\n unsubscribe(handle: string): Promise<void>;\n}\n\nexport { JourneyFactSource } from './facts';\nexport { PostgresJourneyStore } from './postgres';\nexport { PostgresReporting } from './postgres-reporting';\nexport {\n DEFAULT_SCHEMA,\n TABLES,\n renderDrizzleSchema,\n renderPostgresDdl,\n type TableSpec,\n type UniqueConstraintSpec,\n} from './tables';\nexport type { PostgresJourneyStoreOptions, SqlClientLike } from './postgres';\n"],"mappings":";;;;;;;;;;;AA0MA,IAAa,sBAAb,cAAyC,MAAM;CAGxB;CAFrB,OAAyB;CAEzB,YAAY,UAAsC;EAChD,MACE,4IAA4I,SAAS,KAAK,MAAM,GAClK;EAHmB,KAAA,WAAA;CAIrB;AACF"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/store/index.ts"],"sourcesContent":["import type { PushDevice } from '../engine/ports';\nimport type { BundleIR, ConditionIR, ScalarIR, WorkflowIR } from '../ir';\n\n/**\n * The journey engine's data plane. Postgres is the store (`PostgresJourneyStore`); this\n * interface is the port the host, the runner and the fact source use to persist or look\n * anything up, and what a test stub implements in its place. Execution (durable steps,\n * sleeps, wake-ups) is the workflow runtime's job and stays out of here.\n *\n * Timestamps are epoch milliseconds throughout; a store may persist them in\n * its native type as long as `sinceMs` comparisons behave.\n */\n\n/**\n * A profile patch or a stored profile. On the way in, `null` means \"remove\n * this property\" (RFC 7386 merge-patch, the only way the API has to unset\n * one); a stored profile never contains null.\n */\nexport type ProfileProps = Record<string, ScalarIR | null | undefined>;\n\nexport interface EventInput {\n /** The occurrence's identity, chosen by the sender; a second insert with the same id is a no-op. */\n id: string;\n userId: string;\n event: string;\n ts: number;\n payload: Record<string, unknown>;\n}\n\nexport interface TriggerRoute {\n workflow: string;\n hash: string;\n /** Payload gate (payload-only condition tree), or null when the trigger has none. */\n where: ConditionIR | null;\n /** Profile gate, or null. */\n filter: ConditionIR | null;\n /** Whether a visitor not signed in may enter. */\n visitors: boolean;\n /**\n * The code version that applied these routes; absent after a hand-posted `POST /deploy`. Read\n * in the same row as the hash, so the pair is always one deploy's.\n */\n version?: string | undefined;\n}\n\nexport interface SegmentTriggerRoute {\n workflow: string;\n hash: string;\n segment: string;\n /** As `TriggerRoute.version`. */\n version?: string | undefined;\n}\n\n/**\n * A Worker version, as Cloudflare's version metadata binding names it: the bundle compiled into\n * that version goes live only over an older one (`replaceRoutesIfNewer`).\n */\nexport interface BundleVersion {\n id: string;\n /** When the version was created (epoch ms): later versions are newer. */\n createdAt: number;\n}\n\nexport interface EntryInput {\n workflow: string;\n userId: string;\n instanceId: string;\n hash: string;\n ts: number;\n /**\n * The instance's start, recorded in the same transaction as the entry — for a runtime whose\n * start is an asynchronous submit (AWS) that may have to be retried.\n */\n execution?: ExecutionStart | undefined;\n}\n\n/** How an instance is started on a runtime that submits it: replayed as is on every retry. */\nexport interface ExecutionStart {\n /** The runtime's idempotency key (the durable execution name). */\n name: string;\n /** What is invoked: a qualified function ARN, its version included. */\n target: string;\n /** The exact payload: a retried submit must send the same bytes, or the runtime refuses it. */\n input: string;\n}\n\nexport interface ExecutionRecord extends ExecutionStart {\n instanceId: string;\n workflow: string;\n userId: string;\n createdAt: number;\n submittedAt?: number | undefined;\n arn?: string | undefined;\n unresolvedAt?: number | undefined;\n callbackId?: string | undefined;\n /** Bumped by every wait the instance arms: a wake or a cleanup of an older one leaves it alone. */\n generation: number;\n deadlineAt?: number | undefined;\n checkedAt?: number | undefined;\n stoppedAt?: number | undefined;\n}\n\n/**\n * What a runtime that submits its starts and parks waits on one-shot callbacks (AWS) keeps\n * beside the entry ledger. The runtime holds the execution history; this holds only the\n * mapping from the engine's instance id to it.\n */\nexport interface ExecutionRegistry {\n get(instanceId: string): Promise<ExecutionRecord | undefined>;\n /**\n * Starts not yet confirmed and not given up on, oldest first: of one (workflow, user) entry\n * still running, or every one created before `createdBefore`.\n */\n pendingStarts(filter: {\n workflow?: string;\n userId?: string;\n createdBefore?: number;\n limit: number;\n }): Promise<ExecutionRecord[]>;\n markSubmitted(instanceId: string, arn: string | undefined, at: number): Promise<void>;\n markUnresolved(instanceId: string, at: number): Promise<void>;\n setArn(instanceId: string, arn: string): Promise<void>;\n /** Record the callback a wait parks on; false when a newer generation is already armed. */\n armCallback(\n instanceId: string,\n callback: { id: string; generation: number; deadlineAt?: number | undefined }\n ): Promise<boolean>;\n /** Forget the callback — only if it is still that generation's. */\n clearCallback(instanceId: string, generation: number): Promise<void>;\n /** Submitted executions whose entry is still running, least recently checked first. */\n toReconcile(limit: number, checkedBefore: number): Promise<ExecutionRecord[]>;\n markChecked(instanceId: string, at: number): Promise<void>;\n /** Record the first stop; a later one keeps it. */\n markStopped(instanceId: string, at: number): Promise<void>;\n}\n\n/**\n * How an entry attempt ended: a fresh row, a 'failed' row of a dead instance\n * taken over (the once policy does not bar a user over one outage), or\n * refused because the pair already entered and is still alive.\n */\nexport type EntryOutcome = 'entered' | 'reclaimed' | null;\n\n/** A device registered for pushes: its provider token belongs to the user who registered it last. */\nexport interface DeviceInput {\n deviceId?: string | undefined;\n /** Null until the device's visitor signs in. */\n userId: string | null;\n visitorId?: string | undefined;\n token: string;\n /** `ios` (APNs) or `android` (FCM): which provider the token belongs to. */\n platform: string;\n at: number;\n}\n\n/** What linking a visitor to a user did; null when it was already linked, or to another user. */\nexport interface VisitorMerge {\n /** Instances dropped because the user is already in the same workflow: to stop. */\n dropped: string[];\n /** The event names and profile properties the user gained: their waits must wake. */\n events: string[];\n props: string[];\n}\n\n/** An instance of a user still in the ledger, and whether its runner was admitted (see `admitInstance`). */\nexport interface RunningInstance {\n instanceId: string;\n /** False until the runner admitted itself: nothing has run, and the tombstone keeps it that way. */\n admitted: boolean;\n}\n\n/** One (workflow, user) entry: the attempt it records and how far that attempt got. */\nexport interface EntryState {\n instanceId: string;\n /** 'running' until the run finalizes it ('completed', 'goal', 'failed', …). */\n status: string;\n /** The runner admitted itself (`admitInstance`): the instance did reach the runtime. */\n admitted: boolean;\n}\n\n/** An entry still waiting for its first admission (`unadmittedEntries`). */\nexport interface UnadmittedEntry {\n instanceId: string;\n workflow: string;\n userId: string;\n}\n\n/** A running entry whose runner admitted itself (`admittedEntries`). */\nexport interface AdmittedEntry {\n instanceId: string;\n workflow: string;\n userId: string;\n}\n\n/** A store plus how to release what opening it acquired (a connection, say). */\nexport interface StoreLease {\n store: JourneyStore;\n close?: () => Promise<void>;\n}\n\nimport type { ErasureState } from './erasure';\n\nexport { type ErasureState, UserErasedError } from './erasure';\nexport { instanceOwner, isVisitorSubject, visitorIdOf, visitorSubject } from './identity';\n\n/**\n * The live database lacks tables or columns the engine reads (an SDK upgrade added them, or the\n * host never ran the migration). Raised before a deploy writes anything.\n */\nexport class SchemaMismatchError extends Error {\n override readonly name = 'SchemaMismatchError';\n\n constructor(readonly problems: readonly string[]) {\n super(\n `the database does not match @shware/workflow's tables — regenerate the drizzle schema (workflow-ui generate) and run your migrations:\\n ${problems.join('\\n ')}`\n );\n }\n}\n\n/** A message copy as reporting records it, before it goes out. */\nexport interface MessageRecord {\n idempotencyKey: string;\n workflow: string;\n instanceId: string;\n nodeId: string;\n template: string;\n /** Where the copy lands: `email`, `push_ios`, `push_android`. */\n channel: string;\n userId: string;\n /** An email's subscription topic; null for a transactional email and for every other channel. */\n topic: string | null;\n sentAt: number;\n}\n\n/** What is known of a copy without its engagement: whose it is, where it went, its topic. */\nexport interface MessageSummary {\n userId: string;\n channel: string;\n topic: string | null;\n}\n\n/** One opt-out to set or clear: a topic of the channel, or the whole channel (`topic: null`). */\nexport interface SuppressionChange {\n topic: string | null;\n suppressed: boolean;\n}\n\n/** What a click is judged by. */\nexport interface ClickContext {\n url: string;\n deliveredAt: number;\n linkCount: number;\n /** The copy's clicks in the window before this one. */\n recent: { position: number; at: number }[];\n}\n\n/** An open or a click, with the verdict on who made it and where it came from. */\nexport interface Engagement {\n at: number;\n machine: boolean;\n /** The rule that called it a machine; null for a person. */\n reason: string | null;\n userAgent: string | null;\n /** The network the request came from (Cloudflare's `cf.asn`, `cf.asOrganization`, `cf.country`), never the address. */\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\n/**\n * Journey reporting: where each instance stands, every copy and how it was engaged with, segment\n * sizes. The store exposes it as `reporting`; the host records it and serves email tracking.\n * Every write is idempotent — a retried step reports again.\n */\n/** Bounds on when an event happened, both inclusive (epoch ms); an absent side is open. */\nexport interface EventWindow {\n sinceMs?: number;\n untilMs?: number;\n}\n\n/**\n * Where a page of events ended: the last row's timestamp and id, the keyset `(ts, id)` the next\n * page starts after. Opaque to callers; `ts` is the store's own rendering of the timestamp, so a\n * round trip loses no precision.\n */\nexport interface EventCursor {\n ts: string;\n id: string;\n}\n\nexport interface JourneyReporting {\n /** The instance entered: its row exists from here, so one a guard ends at once still counts. */\n instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void>;\n nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void>;\n instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void>;\n /** The copy's row id; the row a retry already wrote, when there is one. */\n recordMessage(message: MessageRecord): Promise<string>;\n /**\n * Whether the copy was settled (`settleMessage`: delivered, or settled undelivered) — read from\n * the database itself, so a replayed send step never trusts a cache that missed the settle.\n */\n messageSettled(idempotencyKey: string): Promise<boolean>;\n /**\n * Mark the copy as handed to the transport, committed before the send: true when this call\n * made the claim, false when an earlier run of the step already held it — the copy may have\n * gone out then, and the engine does not send it again (see `OutboundMessage.redelivery`).\n */\n claimMessage(idempotencyKey: string, at: number): Promise<boolean>;\n /** The transport threw before taking the copy: clear the claim, so the step's retry sends it. */\n releaseMessage(idempotencyKey: string): Promise<void>;\n settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void>;\n /** An email's tracked links, in document order. */\n recordLinks(messageId: string, urls: readonly string[]): Promise<void>;\n /**\n * Record an open of the copy, judged by `judge` against the copy (its recipient and when it was\n * delivered — when it was sent, before the transport answered); `undefined` from the judge\n * records nothing. Read and written in one transaction. False for an unknown copy.\n */\n recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean>;\n /**\n * Record a click on the copy's link at `position`, judged by `judge` against the link, how\n * many links the copy has and its clicks of the last `windowMs`. The copy is locked for the\n * transaction, so clicks arriving together (a scanner following every link at once) are\n * judged one after another, each seeing the ones before. When the judge asks to `backdate`,\n * the window's earlier clicks are reclassified with this one's verdict. A click implies an\n * open. Undefined for an unknown link.\n */\n recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined>;\n /**\n * The email copy's recipient opted out: of the copy's topic, or with `scope: 'all'` of every\n * email. Recorded on the copy and as a suppression (the topic's row, or the channel-wide\n * null-topic row), in one transaction, which also yields who the recipient is and the topic —\n * so the caller acts on the unsubscribe without a second read. A transactional copy\n * (`topic: null`) has nothing to opt out of: nothing is recorded, and the answer says so.\n * Undefined for an unknown copy.\n */\n recordUnsubscribe(\n messageId: string,\n at: number,\n scope?: 'topic' | 'all'\n ): Promise<{ userId: string; topic: string | null } | undefined>;\n /** The copy's owner, channel and topic; undefined for an unknown copy. */\n lookupMessage(messageId: string): Promise<MessageSummary | undefined>;\n /** A push copy the device reported it received. False for an unknown copy. */\n recordDeviceDelivered(messageId: string, at: number): Promise<boolean>;\n /** Drop members of segments no longer deployed, then record every deployed segment's size. */\n snapshotSegments(at: number): Promise<{ segment: string; size: number }[]>;\n}\n\n/** A user the segment sweep has to evaluate, and for which segments. */\nexport interface SegmentCandidate {\n userId: string;\n segments: string[];\n}\n\nexport interface JourneyStore {\n /** Where instances stand, every copy and its engagement, segment sizes. */\n readonly reporting: JourneyReporting;\n\n /**\n * Record that the user's data must go (a deleted account): the tombstone every write about\n * the user is conditional on, from here on and for good. Committed before anything is\n * stopped or erased, so an entry that got in before it is found by the erasure and none gets\n * in after it. Idempotent: a second request, or one for a user already erased, changes nothing.\n */\n requestErasure(userId: string, at: number): Promise<void>;\n /** Users whose erasure was requested and not carried out yet (`'erasing'`), oldest first. */\n pendingErasures(): Promise<string[]>;\n /**\n * Where the user stands: `'none'` (no tombstone), `'erasing'` (requested, their instances\n * being stopped, data still there) or `'erased'` (`forgetUser` went through). Both\n * tombstone states refuse every write about the user; the entry points read this for a fast\n * answer, and the store's conditional writes are the gate itself.\n */\n erasureState(userId: string): Promise<ErasureState>;\n /** The user's journey instances still running, by the entry ledger. */\n runningInstances(userId: string): Promise<RunningInstance[]>;\n /**\n * The runner's admission, before its first observable effect: records that the instance runs\n * (`admitted_at`, kept on replay) — unless the user has an erasure tombstone, in which case\n * nothing is recorded and the runner must not run. Mutually exclusive with `requestErasure`\n * and `forgetUser` for that user (Postgres: the user's advisory lock), so an erasure either\n * finds the instance admitted and stops it, or commits its tombstone first and the admission\n * is refused — never neither.\n *\n * A first admission needs the entry running. An instance already admitted is admitted again\n * whatever the entry's status — the legitimate replay of an instance whose finalisation was\n * cut short after the entry reached its terminal status, which resumes on its own checkpoint\n * and finishes what is left. Never a new attempt's way in: false for a terminal entry never\n * admitted, for an entry no longer in the ledger, for another user's entry, and for an\n * instance whose entry was reclaimed since (`enterJourney` resets the admission with the\n * instance id, so a superseded attempt cannot pass under the id it lost).\n */\n admitInstance(instanceId: string, userId: string): Promise<boolean>;\n /**\n * Erase everything kept about the user — profile, devices, events, wake subscriptions, segment\n * memberships, entries and reporting — and mark the tombstone erased (`erased_at`), which\n * stays: a write that was in flight when the data went cannot bring the user back, and the\n * same id is never reactivated by ingest — which is also why the suppressions go with the\n * rest: nothing is ever sent to the id again, so no opt-out needs keeping. Run only once no\n * instance of theirs runs any more, or one could write some of it back. Idempotent. Mutually\n * exclusive with every write about the user (the user's advisory lock), so the data is gone\n * when this returns — nothing of theirs lands afterwards.\n */\n forgetUser(userId: string, at: number): Promise<void>;\n\n /**\n * The user opted out of the channel (recorded by an unsubscribe, or set through\n * `/subscriptions`): with a `topic`, an opt-out of that topic or of the whole channel; without\n * one, only the whole channel.\n */\n isSuppressed(userId: string, channel: string, topic?: string): Promise<boolean>;\n /** Every opt-out of the user on the channel: the topics, and `null` for the whole channel. */\n listSuppressions(userId: string, channel: string): Promise<(string | null)[]>;\n /**\n * Set or clear opt-outs, all in one transaction: a suppressed change inserts the row (keeping\n * the time of one already there), an unsuppressed one deletes it. Written whatever the user's\n * erasure state — the caller refuses an erased user first — since an opt-out is never refused.\n */\n updateSuppressions(\n userId: string,\n channel: string,\n changes: readonly SuppressionChange[],\n at: number\n ): Promise<void>;\n\n /**\n * What the live database lacks against the engine's tables (read-only); a deploy refuses to\n * run while it lists anything.\n */\n checkSchema(): Promise<string[]>;\n\n /* ---------------------------------- events --------------------------------- */\n /**\n * Append the occurrence; false when its id was already in the log (nothing written). Throws\n * `UserErasedError` for a user with a tombstone, like every write about a user below.\n */\n insertEvent(input: EventInput): Promise<boolean>;\n /**\n * Occurrences of `event` for the user, optionally only those within the window. With\n * `atLeast`, counting may stop there: the result is exact below it and some number ≥ it\n * otherwise — all a `performed(…, { count })` check needs, without counting a heavy user's\n * whole history.\n */\n countEvents(\n userId: string,\n event: string,\n window?: EventWindow,\n atLeast?: number\n ): Promise<number>;\n /**\n * One page of those occurrences' payloads, oldest first — the caller filters them with the\n * shared evaluator and pages on with `next` only while it still needs matches, so a `where`\n * never loads a user's whole history at once. `next` is absent on the last page.\n */\n pageEventPayloads(\n userId: string,\n event: string,\n window: EventWindow | undefined,\n page: { limit: number; after?: EventCursor | undefined }\n ): Promise<{ payloads: Record<string, unknown>[]; next?: EventCursor | undefined }>;\n\n /* -------------------------------- visitors --------------------------------- */\n /** The user a visitor became, if they did. */\n linkedUser(visitorId: string): Promise<string | undefined>;\n /** The visitor was heard from: who they are now, the user they became or their own subject. */\n touchVisitor(visitorId: string, at: number): Promise<string>;\n /**\n * Forget visitors who never signed in and were last heard from before `before`, with no journey\n * running: up to `limit` of them, leaving no tombstone. Answers how many.\n */\n pruneVisitors(before: number, limit: number): Promise<number>;\n /**\n * The visitor became the user: their events, profile, devices, opt-outs and journeys move to the\n * user, once. A visitor already linked keeps its first user. The user's own entry wins over the\n * visitor's in the same workflow, and the visitor's instance is dropped.\n */\n linkVisitor(visitorId: string, userId: string, at: number): Promise<VisitorMerge | null>;\n /** Who an instance's entry belongs to now. */\n entryOwner(instanceId: string): Promise<string | undefined>;\n\n /* --------------------------------- profiles -------------------------------- */\n getProfile(userId: string): Promise<ProfileProps | undefined>;\n /**\n * Merge-patch `props` into the stored profile (a null removes the property) and return the\n * result, atomically: two concurrent identifies cannot lose each other's keys. `at` is when the\n * values were read (now by default): a property written with a later `at` keeps its value, so\n * a redelivered patch cannot undo a newer one.\n */\n mergeProfile(userId: string, props: ProfileProps, at?: number): Promise<ProfileProps>;\n\n /**\n * Who the segment sweep has to evaluate: for each user the engine knows (a profile, an event or\n * a membership), the segments whose membership may have to change, or whose entry still owes a\n * start. It may name a user whose membership turns out right, since `evaluateCondition` decides;\n * it must not leave out one whose membership is wrong. Users come in ascending order; `after`\n * resumes past the last id of the previous page, and users with nothing to do are left out.\n */\n listSegmentCandidates(page: {\n segments: readonly { id: string; condition: ConditionIR }[];\n now: number;\n after?: string | undefined;\n limit: number;\n }): Promise<SegmentCandidate[]>;\n\n /* ---------------------------------- devices --------------------------------- */\n /** The push devices a user or visitor holds now, most recently registered first. */\n listDevices(subject: string): Promise<PushDevice[]>;\n /**\n * Register a device: one row per token and per device, holding only its current owner. A token\n * registered again moves to whoever registered it last (a phone signed into another account) and\n * to the front of their list; a device's new token replaces its old one. Answers the previous\n * owners, whose device waits must wake.\n */\n upsertDevice(device: DeviceInput): Promise<string[]>;\n /** Forget a token (reported dead by APNs / FCM); the user or visitor it belonged to, if any. */\n removeDevice(token: string): Promise<string | undefined>;\n /**\n * The user signed out on the device: the token is kept, bound to nobody, until the next\n * registration binds it again. Who it belonged to, if anyone.\n */\n unbindDevice(token: string): Promise<string | undefined>;\n\n /* ---------------------------- deployed definitions -------------------------- */\n getSegmentCondition(id: string): Promise<ConditionIR | undefined>;\n /** Every deployed segment with its definition: what the segment sweep evaluates. */\n listSegments(): Promise<{ id: string; condition: ConditionIR }[]>;\n findTriggers(event: string): Promise<TriggerRoute[]>;\n listSegmentTriggers(): Promise<SegmentTriggerRoute[]>;\n putWorkflowIR(workflow: WorkflowIR): Promise<void>;\n getWorkflowIR(contentHash: string): Promise<WorkflowIR | undefined>;\n /**\n * Swap the routing tables (segments, event triggers, segment triggers) for\n * the bundle's — atomically, so a concurrent ingest never sees them half\n * empty. Workflow bodies are stored separately (`putWorkflowIR`) because they\n * are content-addressed and outlive the routes that point at them.\n *\n * With `version` — a forced apply of the code's own bundle (`POST /bundle/deploy`) — the\n * version is recorded too, unconditionally and in the same transaction, so the live version\n * always names the code the routes came from. Without it (a hand-posted `POST /deploy`) the\n * recorded version stays as it was: that bundle holds until a newer code version applies its own.\n */\n replaceRoutes(bundle: BundleIR, version?: BundleVersion): Promise<void>;\n /**\n * The Worker version whose bundle the routes came from, when one put them there (through\n * `replaceRoutesIfNewer`, or a forced `replaceRoutes` with a version). Read from the database\n * itself, not a cache in front of it.\n */\n liveBundleVersion(): Promise<BundleVersion | undefined>;\n /**\n * `replaceRoutes`, only when `version` is newer than the live one, recorded in the same\n * transaction: two versions serving side by side during a rollout cannot leave the older one's\n * routes in force. False, with nothing written, when a version as new or newer is live.\n */\n replaceRoutesIfNewer(bundle: BundleIR, version: BundleVersion): Promise<boolean>;\n\n /* ----------------------------- segment membership --------------------------- */\n isSegmentMember(segment: string, userId: string): Promise<boolean>;\n /** Record the membership, with the workflows its entry has to start (see segment_start). */\n addSegmentMember(\n segment: string,\n userId: string,\n ts: number,\n workflows: readonly string[]\n ): Promise<void>;\n /** The workflows the member's entry has not started yet. */\n pendingSegmentStarts(segment: string, userId: string): Promise<string[]>;\n completeSegmentStart(segment: string, userId: string, workflow: string): Promise<void>;\n /** Drop the membership and any start it still owed. */\n removeSegmentMember(segment: string, userId: string): Promise<void>;\n\n /* ------------------------------- entry ledger ------------------------------- */\n /**\n * Record the entry (the once policy). Atomic under concurrent ingests: of\n * two racing attempts exactly one is 'entered' (or 'reclaimed', when the\n * pair's previous instance is recorded as 'failed') and the other null.\n */\n enterJourney(entry: EntryInput): Promise<EntryOutcome>;\n removeEntry(instanceId: string): Promise<void>;\n setEntryStatus(instanceId: string, status: string): Promise<void>;\n /** The (workflow, user) entry as it stands, read from the database itself; undefined if none. */\n getEntry(workflow: string, userId: string): Promise<EntryState | undefined>;\n /**\n * Running entries no runner has admitted, claimed before `claimedBefore` (epoch ms), oldest\n * first: candidates for a start that was lost between the claim and the platform's create.\n */\n unadmittedEntries(claimedBefore: number, limit: number): Promise<UnadmittedEntry[]>;\n /**\n * Mark a lost start `failed`, so the pair's next trigger reclaims it. Only while the entry is\n * still running and unadmitted under this instance id; false when it moved on meanwhile.\n */\n failUnadmittedEntry(instanceId: string): Promise<boolean>;\n /**\n * Running entries whose runner admitted itself before `admittedBefore` (epoch ms): candidates\n * for an instance the platform ended without the run finalizing its entry. A sample of at most\n * `limit`, in no fixed order — ordered by age, the oldest entries still legitimately running\n * (a long wait) would fill every batch and a lost one behind them would never be reached.\n */\n admittedEntries(admittedBefore: number, limit: number): Promise<AdmittedEntry[]>;\n /**\n * Mark an admitted entry whose instance ended without finalizing `failed`, so the pair's next\n * trigger reclaims it. Only while the entry is still running and admitted under this instance\n * id: a run that finalized meanwhile keeps its status, and a reclaim its new attempt; false\n * then.\n */\n failAdmittedEntry(instanceId: string): Promise<boolean>;\n /** Execution starts and wake callbacks, for a runtime that submits its starts (AWS). */\n readonly executions: ExecutionRegistry;\n\n /* ------------------------------ wake subscriptions --------------------------- */\n /** Distinct wake handles registered for (user, event). */\n findWakeHandles(userId: string, event: string): Promise<string[]>;\n /** Register the handle for each event; repeated registrations are idempotent. */\n subscribe(handle: string, userId: string, events: readonly string[], ts: number): Promise<void>;\n /** Drop every registration the handle holds. */\n unsubscribe(handle: string): Promise<void>;\n}\n\nexport { JourneyFactSource } from './facts';\nexport { PostgresJourneyStore } from './postgres';\nexport { PostgresReporting } from './postgres-reporting';\nexport {\n DEFAULT_SCHEMA,\n TABLES,\n renderDrizzleSchema,\n renderPostgresDdl,\n type TableSpec,\n type UniqueConstraintSpec,\n} from './tables';\nexport type { PostgresJourneyStoreOptions, SqlClientLike } from './postgres';\n"],"mappings":";;;;;;;;;;;AAiNA,IAAa,sBAAb,cAAyC,MAAM;CAGxB;CAFrB,OAAyB;CAEzB,YAAY,UAAsC;EAChD,MACE,4IAA4I,SAAS,KAAK,MAAM,GAClK;EAHmB,KAAA,WAAA;CAIrB;AACF"}
|
|
@@ -137,6 +137,15 @@ var PostgresReporting = class {
|
|
|
137
137
|
return (await this.sql.transaction((tx) => tx.query(`SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1
|
|
138
138
|
AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`, [idempotencyKey]))).length > 0;
|
|
139
139
|
}
|
|
140
|
+
async claimMessage(idempotencyKey, at) {
|
|
141
|
+
return (await this.sql.query(`UPDATE ${this.t.message} SET claimed_at = ${TS("$2")}
|
|
142
|
+
WHERE idempotency_key = $1 AND claimed_at IS NULL
|
|
143
|
+
RETURNING 1 AS x`, [idempotencyKey, at])).length > 0;
|
|
144
|
+
}
|
|
145
|
+
async releaseMessage(idempotencyKey) {
|
|
146
|
+
await this.sql.query(`UPDATE ${this.t.message} SET claimed_at = NULL
|
|
147
|
+
WHERE idempotency_key = $1 AND delivered_at IS NULL AND undelivered_reason IS NULL`, [idempotencyKey]);
|
|
148
|
+
}
|
|
140
149
|
async settleMessage(idempotencyKey, result, at) {
|
|
141
150
|
if (result.delivered) await this.sql.query(`UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS("$2")}) WHERE idempotency_key = $1`, [idempotencyKey, at]);
|
|
142
151
|
else await this.sql.query(`UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`, [idempotencyKey, result.reason]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.cjs","names":["beginReadCommitted","lockUser","lockUserTransaction","uuidv7","UserErasedError"],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.cjs","names":["beginReadCommitted","lockUser","lockUserTransaction","uuidv7","UserErasedError"],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async claimMessage(idempotencyKey: string, at: number): Promise<boolean> {\n // One statement, so two runs of the same step cannot both claim: only the run that finds\n // claimed_at null writes it, and gets a row back. A write, so Hyperdrive runs it on the\n // database — no cache can answer \"unclaimed\" for a copy a crashed run claimed\n const rows = await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = ${TS('$2')}\n WHERE idempotency_key = $1 AND claimed_at IS NULL\n RETURNING 1 AS x`,\n [idempotencyKey, at]\n );\n return rows.length > 0;\n }\n\n async releaseMessage(idempotencyKey: string): Promise<void> {\n await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = NULL\n WHERE idempotency_key = $1 AND delivered_at IS NULL AND undelivered_reason IS NULL`,\n [idempotencyKey]\n );\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
|
@@ -60,6 +60,8 @@ export declare class PostgresReporting implements JourneyReporting {
|
|
|
60
60
|
}): Promise<void>;
|
|
61
61
|
recordMessage(message: MessageRecord): Promise<string>;
|
|
62
62
|
messageSettled(idempotencyKey: string): Promise<boolean>;
|
|
63
|
+
claimMessage(idempotencyKey: string, at: number): Promise<boolean>;
|
|
64
|
+
releaseMessage(idempotencyKey: string): Promise<void>;
|
|
63
65
|
settleMessage(idempotencyKey: string, result: {
|
|
64
66
|
delivered: true;
|
|
65
67
|
} | {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.d.cts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.d.cts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,aAAa,wBAAwB,aAAa;EAalD,eAAe,yBAAyB;EAQxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
@@ -60,6 +60,8 @@ export declare class PostgresReporting implements JourneyReporting {
|
|
|
60
60
|
}): Promise<void>;
|
|
61
61
|
recordMessage(message: MessageRecord): Promise<string>;
|
|
62
62
|
messageSettled(idempotencyKey: string): Promise<boolean>;
|
|
63
|
+
claimMessage(idempotencyKey: string, at: number): Promise<boolean>;
|
|
64
|
+
releaseMessage(idempotencyKey: string): Promise<void>;
|
|
63
65
|
settleMessage(idempotencyKey: string, result: {
|
|
64
66
|
delivered: true;
|
|
65
67
|
} | {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.d.mts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.d.mts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,aAAa,wBAAwB,aAAa;EAalD,eAAe,yBAAyB;EAQxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
@@ -136,6 +136,15 @@ var PostgresReporting = class {
|
|
|
136
136
|
return (await this.sql.transaction((tx) => tx.query(`SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1
|
|
137
137
|
AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`, [idempotencyKey]))).length > 0;
|
|
138
138
|
}
|
|
139
|
+
async claimMessage(idempotencyKey, at) {
|
|
140
|
+
return (await this.sql.query(`UPDATE ${this.t.message} SET claimed_at = ${TS("$2")}
|
|
141
|
+
WHERE idempotency_key = $1 AND claimed_at IS NULL
|
|
142
|
+
RETURNING 1 AS x`, [idempotencyKey, at])).length > 0;
|
|
143
|
+
}
|
|
144
|
+
async releaseMessage(idempotencyKey) {
|
|
145
|
+
await this.sql.query(`UPDATE ${this.t.message} SET claimed_at = NULL
|
|
146
|
+
WHERE idempotency_key = $1 AND delivered_at IS NULL AND undelivered_reason IS NULL`, [idempotencyKey]);
|
|
147
|
+
}
|
|
139
148
|
async settleMessage(idempotencyKey, result, at) {
|
|
140
149
|
if (result.delivered) await this.sql.query(`UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS("$2")}) WHERE idempotency_key = $1`, [idempotencyKey, at]);
|
|
141
150
|
else await this.sql.query(`UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`, [idempotencyKey, result.reason]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.mjs","names":[],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.mjs","names":[],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async claimMessage(idempotencyKey: string, at: number): Promise<boolean> {\n // One statement, so two runs of the same step cannot both claim: only the run that finds\n // claimed_at null writes it, and gets a row back. A write, so Hyperdrive runs it on the\n // database — no cache can answer \"unclaimed\" for a copy a crashed run claimed\n const rows = await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = ${TS('$2')}\n WHERE idempotency_key = $1 AND claimed_at IS NULL\n RETURNING 1 AS x`,\n [idempotencyKey, at]\n );\n return rows.length > 0;\n }\n\n async releaseMessage(idempotencyKey: string): Promise<void> {\n await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = NULL\n WHERE idempotency_key = $1 AND delivered_at IS NULL AND undelivered_reason IS NULL`,\n [idempotencyKey]\n );\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
package/dist/store/postgres.cjs
CHANGED
|
@@ -744,6 +744,20 @@ var PostgresJourneyStore = class {
|
|
|
744
744
|
async failUnadmittedEntry(instanceId) {
|
|
745
745
|
return (await this.sql.query(`UPDATE ${this.t.entry} SET status = 'failed'
|
|
746
746
|
WHERE instance_id = $1 AND status = 'running' AND admitted_at IS NULL
|
|
747
|
+
RETURNING instance_id`, [instanceId])).length > 0;
|
|
748
|
+
}
|
|
749
|
+
async admittedEntries(admittedBefore, limit) {
|
|
750
|
+
return (await this.sql.query(`SELECT instance_id, workflow, user_id FROM ${this.t.entry}
|
|
751
|
+
WHERE status = 'running' AND admitted_at < ${TS("$1")}
|
|
752
|
+
ORDER BY random() LIMIT $2`, [admittedBefore, limit])).map((row) => ({
|
|
753
|
+
instanceId: row.instance_id,
|
|
754
|
+
workflow: row.workflow,
|
|
755
|
+
userId: row.user_id
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
758
|
+
async failAdmittedEntry(instanceId) {
|
|
759
|
+
return (await this.sql.query(`UPDATE ${this.t.entry} SET status = 'failed'
|
|
760
|
+
WHERE instance_id = $1 AND status = 'running' AND admitted_at IS NOT NULL
|
|
747
761
|
RETURNING instance_id`, [instanceId])).length > 0;
|
|
748
762
|
}
|
|
749
763
|
async setEntryStatus(instanceId, status) {
|