@shware/workflow 0.2.0 → 0.3.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 +688 -10
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +11 -0
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +11 -0
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +1 -0
- package/dist/cloudflare/index.d.cts +3 -3
- package/dist/cloudflare/index.d.mts +3 -3
- package/dist/cloudflare/index.mjs +2 -2
- package/dist/cloudflare/router.cjs +21 -4
- package/dist/cloudflare/router.cjs.map +1 -1
- package/dist/cloudflare/router.d.cts +18 -3
- package/dist/cloudflare/router.d.cts.map +1 -1
- package/dist/cloudflare/router.d.mts +18 -3
- package/dist/cloudflare/router.d.mts.map +1 -1
- package/dist/cloudflare/router.mjs +21 -5
- package/dist/cloudflare/router.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +37 -1
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +37 -1
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +21 -0
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +21 -0
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres.cjs +45 -20
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +4 -1
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +4 -1
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +45 -20
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +11 -0
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +1 -0
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +1 -0
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +11 -0
- package/dist/store/tables.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport type { RegisteredAction } from '../engine/actions';\nimport type { JourneyObserver, MessageSender, MessageValidator } from '../engine/ports';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { ScalarIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport { handleTracking } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport { type JourneyStore, SchemaMismatchError, type StoreLease } from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { TemplateValidationError, registryValidator } from '../templates';\nimport type { JourneyEnv, JourneyParams } from './bindings';\nimport { channelsFromConfig, runtimeFromConfig } from './channels';\nimport { d1Store } from './d1-env';\nimport {\n type SweepResult,\n deployBundle,\n handleRequest,\n ingestEvent,\n sweepSegments,\n} from './router';\nimport { JourneyRunner } from './runner';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\nexport interface JourneyWorkerOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the Worker\n * deploys (`GET /bundle`, `POST /bundle/deploy` serve `compileConfig`), the\n * template registries the senders, the validator and `/preview` render from,\n * the contract every `/events` and `/identify` is checked against, the email\n * envelope, actions, and the runtime / APNs / FCM settings. Secrets come\n * from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a\n * `StoreLease` when the client must be released afterwards (a Postgres\n * connection); defaults to D1 + KV from the env's bindings. `database` is\n * the config's (`schema` defaulting to `workflow`), for a Postgres store:\n *\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the Worker\n * serves the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report\n * elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway); by default the channels the config describes. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the Worker runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n}\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\n/** The Worker's environment failed its schema (`journeyWorker({ env })`). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the Worker's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const { config } = options;\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n if (!result.success) {\n // Names and reasons only: the values are secrets\n throw new EnvValidationError(\n result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)\n );\n }\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const bundle = () => compileConfig(config);\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n private current: JourneyStore | undefined;\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = lease(checkEnv(this.env as Env));\n this.leases.set(store, close);\n this.current = store;\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override contract(): Contract | undefined {\n return config.contract;\n }\n\n protected override createMessageValidator(): MessageValidator {\n return registryValidator(config.templates ?? {});\n }\n\n protected override createMessageSender(): MessageSender {\n if (this.current === undefined) return super.createMessageSender();\n if (options.messages !== undefined) {\n return options.messages(checkEnv(this.env as Env), this.current);\n }\n return channelsFromConfig(config, checkEnv(this.env as Env), { store: this.current });\n }\n\n protected override createObserver(store: JourneyStore): JourneyObserver | undefined {\n if (options.observer !== undefined) return options.observer(checkEnv(this.env as Env), store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, checkEnv(this.env as Env).WORKFLOW_ORIGIN);\n }\n\n protected override runtime(params: JourneyParams): JourneyRuntimeOptions {\n const env = checkEnv(this.env as Env);\n if (options.runtime !== undefined) return options.runtime(env, params);\n return runtimeFromConfig(config, env);\n }\n\n protected override actions(): readonly RegisteredAction[] {\n // An action ref is a registered action structurally: the same object carries name, code and hash\n return config.actions ?? [];\n }\n }\n\n const json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\n const authorized = (request: Request, env: Env): boolean =>\n env.API_TOKEN === undefined ||\n request.headers.get('authorization') === `Bearer ${env.API_TOKEN}`;\n\n const preview = emails === undefined ? undefined : registryRenderer(emails);\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n // Everything below reads the validated environment (the schema's defaults applied)\n let env: Env;\n try {\n env = checkEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return json({ error: error.message }, 500);\n }\n const url = new URL(request.url);\n const { store, close } = lease(env);\n try {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n if (url.pathname.startsWith('/t/') && store.reporting !== undefined) {\n const openedEvent = config.webPush?.openedEvent;\n const tracked = await handleTracking(request, store.reporting, {\n unsubscribe: config.emails?.unsubscribe,\n product: config.title,\n quickMs: config.emails?.tracking?.quickClickMs,\n pushOpened:\n openedEvent === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload: Record<string, unknown> = { message_id: messageId };\n const contract = config.contract;\n const verdict =\n contract === undefined ? undefined : checkEvent(contract, openedEvent, payload);\n if (verdict !== undefined && !verdict.ok) {\n throw new ContractValidationError(openedEvent, verdict.issues);\n }\n // Keyed by the copy: a second tap of the same notification is the same event\n await ingestEvent(\n env,\n {\n id: `${openedEvent}:${messageId}`,\n userId,\n event: openedEvent,\n payload: verdict?.data ?? payload,\n ts: at,\n },\n store\n );\n },\n });\n if (tracked !== undefined) return tracked;\n }\n if (url.pathname === '/segments/sweep') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n return json(await sweepSegments(env, store));\n }\n if (url.pathname === '/bundle') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n return json(bundle());\n }\n if (url.pathname === '/bundle/deploy') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n try {\n return json(await deployBundle(env, bundle(), store));\n } catch (error) {\n if (!(error instanceof SchemaMismatchError)) throw error;\n return json({ error: error.message, problems: error.problems }, 503);\n }\n }\n if (emails !== undefined && preview !== undefined && url.pathname.startsWith('/preview/')) {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n const key = url.pathname.slice('/preview/'.length);\n if (!Object.hasOwn(emails, key))\n return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown = {};\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n try {\n const { subject, html } = await preview(key, props as Record<string, ScalarIR>);\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'x-subject': subject },\n });\n } catch (error) {\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n throw error;\n }\n }\n return await handleRequest(request, env, store, {\n contract: config.contract,\n mode: options.contractMode,\n });\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = async (rawEnv: Env) => {\n const env = checkEnv(rawEnv);\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n const scheduled = async (_controller: unknown, env: Env, ctx: ExecutionContextLike) => {\n ctx.waitUntil(sweep(env));\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport type { RegisteredAction } from '../engine/actions';\nimport type { JourneyObserver, MessageSender, MessageValidator } from '../engine/ports';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { ScalarIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport { handleTracking } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport {\n type BundleVersion,\n type JourneyStore,\n SchemaMismatchError,\n type StoreLease,\n} from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { TemplateValidationError, registryValidator } from '../templates';\nimport type { JourneyEnv, JourneyParams } from './bindings';\nimport { channelsFromConfig, runtimeFromConfig } from './channels';\nimport { d1Store } from './d1-env';\nimport {\n type SweepResult,\n deployBundle,\n handleRequest,\n ingestEvent,\n sweepSegments,\n} from './router';\nimport { JourneyRunner } from './runner';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\nexport interface JourneyWorkerOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the Worker\n * deploys (`GET /bundle`, `POST /bundle/deploy` serve `compileConfig`), the\n * template registries the senders, the validator and `/preview` render from,\n * the contract every `/events` and `/identify` is checked against, the email\n * envelope, actions, and the runtime / APNs / FCM settings. Secrets come\n * from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a\n * `StoreLease` when the client must be released afterwards (a Postgres\n * connection); defaults to D1 + KV from the env's bindings. `database` is\n * the config's (`schema` defaulting to `workflow`), for a Postgres store:\n *\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the Worker\n * serves the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report\n * elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway); by default the channels the config describes. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the Worker runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n}\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\n/** The Worker's environment failed its schema (`journeyWorker({ env })`). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the Worker's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const { config } = options;\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n if (!result.success) {\n // Names and reasons only: the values are secrets\n throw new EnvValidationError(\n result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)\n );\n }\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const bundle = () => compileConfig(config);\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n private current: JourneyStore | undefined;\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = lease(checkEnv(this.env as Env));\n this.leases.set(store, close);\n this.current = store;\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override contract(): Contract | undefined {\n return config.contract;\n }\n\n protected override createMessageValidator(): MessageValidator {\n return registryValidator(config.templates ?? {});\n }\n\n protected override createMessageSender(): MessageSender {\n if (this.current === undefined) return super.createMessageSender();\n if (options.messages !== undefined) {\n return options.messages(checkEnv(this.env as Env), this.current);\n }\n return channelsFromConfig(config, checkEnv(this.env as Env), { store: this.current });\n }\n\n protected override createObserver(store: JourneyStore): JourneyObserver | undefined {\n if (options.observer !== undefined) return options.observer(checkEnv(this.env as Env), store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, checkEnv(this.env as Env).WORKFLOW_ORIGIN);\n }\n\n protected override runtime(params: JourneyParams): JourneyRuntimeOptions {\n const env = checkEnv(this.env as Env);\n if (options.runtime !== undefined) return options.runtime(env, params);\n return runtimeFromConfig(config, env);\n }\n\n protected override actions(): readonly RegisteredAction[] {\n // An action ref is a registered action structurally: the same object carries name, code and hash\n return config.actions ?? [];\n }\n }\n\n const json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\n const authorized = (request: Request, env: Env): boolean =>\n env.API_TOKEN === undefined ||\n request.headers.get('authorization') === `Bearer ${env.API_TOKEN}`;\n\n const preview = emails === undefined ? undefined : registryRenderer(emails);\n\n // The bundle compiled into this version goes live on its own, once per isolate: the first\n // request or cron run reads which version's bundle is live (one row) and applies this one only\n // over an older version, so one still serving during a rollout can never put its routes back.\n // A failure (say the migrations have not run) is logged and retried a minute later. Only the\n // request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env): Promise<void> | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined || metadata.id === '') return undefined;\n const version: BundleVersion = { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n if (Number.isNaN(version.createdAt)) return undefined;\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n if (store.liveBundleVersion === undefined) return; // D1: POST /bundle/deploy\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n // Everything below reads the validated environment (the schema's defaults applied)\n let env: Env;\n try {\n env = checkEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return json({ error: error.message }, 500);\n }\n const pending = applyOwnBundle(env);\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const url = new URL(request.url);\n const { store, close } = lease(env);\n try {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n if (url.pathname.startsWith('/t/') && store.reporting !== undefined) {\n const openedEvent = config.webPush?.openedEvent;\n const tracked = await handleTracking(request, store.reporting, {\n unsubscribe: config.emails?.unsubscribe,\n product: config.title,\n quickMs: config.emails?.tracking?.quickClickMs,\n pushOpened:\n openedEvent === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload: Record<string, unknown> = { message_id: messageId };\n const contract = config.contract;\n const verdict =\n contract === undefined ? undefined : checkEvent(contract, openedEvent, payload);\n if (verdict !== undefined && !verdict.ok) {\n throw new ContractValidationError(openedEvent, verdict.issues);\n }\n // Keyed by the copy: a second tap of the same notification is the same event\n await ingestEvent(\n env,\n {\n id: `${openedEvent}:${messageId}`,\n userId,\n event: openedEvent,\n payload: verdict?.data ?? payload,\n ts: at,\n },\n store\n );\n },\n });\n if (tracked !== undefined) return tracked;\n }\n if (url.pathname === '/segments/sweep') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n return json(await sweepSegments(env, store));\n }\n if (url.pathname === '/bundle') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n return json(bundle());\n }\n if (url.pathname === '/bundle/deploy') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n try {\n return json(await deployBundle(env, bundle(), store));\n } catch (error) {\n if (!(error instanceof SchemaMismatchError)) throw error;\n return json({ error: error.message, problems: error.problems }, 503);\n }\n }\n if (emails !== undefined && preview !== undefined && url.pathname.startsWith('/preview/')) {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n const key = url.pathname.slice('/preview/'.length);\n if (!Object.hasOwn(emails, key))\n return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown = {};\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n try {\n const { subject, html } = await preview(key, props as Record<string, ScalarIR>);\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'x-subject': subject },\n });\n } catch (error) {\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n throw error;\n }\n }\n return await handleRequest(request, env, store, {\n contract: config.contract,\n mode: options.contractMode,\n });\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = async (rawEnv: Env) => {\n const env = checkEnv(rawEnv);\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n const scheduled = async (_controller: unknown, env: Env, ctx: ExecutionContextLike) => {\n // A version with no traffic yet goes live here; the sweep then reads its segments\n ctx.waitUntil(\n (async () => {\n await applyOwnBundle(checkEnv(env));\n await sweep(env);\n })()\n );\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":"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"}
|
package/dist/store/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","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 as a port. Everything the ingest router, the\n * runner and the fact source persist or look up goes through this interface,\n * so the storage engine is a deployment choice: D1 + KV (`D1JourneyStore`,\n * the original), Postgres (`PostgresJourneyStore`), or anything else that can\n * satisfy the contract below. Execution (durable steps, sleeps, wake-ups) is\n * 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}\n\nexport interface SegmentTriggerRoute {\n workflow: string;\n hash: string;\n segment: string;\n}\n\nexport interface EntryInput {\n workflow: string;\n userId: string;\n instanceId: string;\n hash: string;\n ts: number;\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 userId: string;\n token: string;\n /** `ios` (APNs) or `android` (FCM): which provider the token belongs to. */\n platform: string;\n ts: number;\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\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 sentAt: number;\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. A store that keeps it (Postgres) exposes it as `reporting`; the Worker then records it\n * and serves email tracking. 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\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 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 /** The email copy's recipient opted out of email: recorded on the copy and as a suppression. False for an unknown copy. */\n recordUnsubscribe(messageId: string, at: number): Promise<boolean>;\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\nexport interface JourneyStore {\n /** Reporting, when the store keeps it (Postgres); D1 stores none. */\n readonly reporting?: JourneyReporting;\n\n /**\n * Record that the user's data must go (a deleted account), before anything is erased: the\n * request outlives a failure to stop their instances, and the sweep retries it.\n */\n requestErasure(userId: string, at: number): Promise<void>;\n /** Users whose erasure was requested and not finished yet. */\n pendingErasures(): Promise<string[]>;\n /** The user's journey instances still running, by the entry ledger. */\n runningInstances(userId: string): Promise<string[]>;\n /**\n * Erase everything kept about the user — profile, devices, events, wake subscriptions, segment\n * memberships, suppressions, entries and reporting — and the erasure request last. Run only\n * once no instance of theirs runs any more, or one could write some of it back.\n */\n forgetUser(userId: string): Promise<void>;\n\n /** The user opted out of the channel (recorded by an unsubscribe); stores without reporting omit it. */\n isSuppressed?(userId: string, channel: string): Promise<boolean>;\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. Stores whose schema the engine owns (D1) omit it.\n */\n checkSchema?(): Promise<string[]>;\n\n /* ---------------------------------- events --------------------------------- */\n /** Append the occurrence; false when its id was already in the log (nothing written). */\n insertEvent(input: EventInput): Promise<boolean>;\n /** Occurrences of `event` for the user, optionally only those within the window. */\n countEvents(userId: string, event: string, window?: EventWindow): Promise<number>;\n /** The payloads of those occurrences — the caller filters them with the shared evaluator. */\n listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]>;\n\n /* --------------------------------- profiles -------------------------------- */\n getProfile(userId: string): Promise<ProfileProps | undefined>;\n /**\n * Merge-patch `props` into the stored profile in one statement (a null\n * removes the property) and return the result. One statement, because a\n * read-modify-write loses one of two concurrent identifies.\n */\n mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps>;\n\n /** Every user the engine knows (a profile or an event): who the segment sweep evaluates. */\n listUserIds(): Promise<string[]>;\n\n /* ---------------------------------- devices --------------------------------- */\n /** The user's push devices, most recently registered first. */\n listDevices(userId: string): Promise<PushDevice[]>;\n /**\n * Register a device: one row per token, so a token registered again moves to the user who\n * registered it last (a phone signed into another account) and to the front of their list.\n */\n upsertDevice(device: DeviceInput): Promise<void>;\n /** Forget a token (reported dead by APNs / FCM); the user 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 sign-in\n * binds it again. The user it belonged to, if any.\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 replaceRoutes(bundle: BundleIR): Promise<void>;\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\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 { D1JourneyStore } from './d1';\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} from './tables';\nexport type { PostgresJourneyStoreOptions, SqlClientLike } from './postgres';\n"],"mappings":";;;;;;;;;;;AAgFA,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.cjs","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 as a port. Everything the ingest router, the\n * runner and the fact source persist or look up goes through this interface,\n * so the storage engine is a deployment choice: D1 + KV (`D1JourneyStore`,\n * the original), Postgres (`PostgresJourneyStore`), or anything else that can\n * satisfy the contract below. Execution (durable steps, sleeps, wake-ups) is\n * 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}\n\nexport interface SegmentTriggerRoute {\n workflow: string;\n hash: string;\n segment: string;\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\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 userId: string;\n token: string;\n /** `ios` (APNs) or `android` (FCM): which provider the token belongs to. */\n platform: string;\n ts: number;\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\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 sentAt: number;\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. A store that keeps it (Postgres) exposes it as `reporting`; the Worker then records it\n * and serves email tracking. 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\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 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 /** The email copy's recipient opted out of email: recorded on the copy and as a suppression. False for an unknown copy. */\n recordUnsubscribe(messageId: string, at: number): Promise<boolean>;\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\nexport interface JourneyStore {\n /** Reporting, when the store keeps it (Postgres); D1 stores none. */\n readonly reporting?: JourneyReporting;\n\n /**\n * Record that the user's data must go (a deleted account), before anything is erased: the\n * request outlives a failure to stop their instances, and the sweep retries it.\n */\n requestErasure(userId: string, at: number): Promise<void>;\n /** Users whose erasure was requested and not finished yet. */\n pendingErasures(): Promise<string[]>;\n /** The user's journey instances still running, by the entry ledger. */\n runningInstances(userId: string): Promise<string[]>;\n /**\n * Erase everything kept about the user — profile, devices, events, wake subscriptions, segment\n * memberships, suppressions, entries and reporting — and the erasure request last. Run only\n * once no instance of theirs runs any more, or one could write some of it back.\n */\n forgetUser(userId: string): Promise<void>;\n\n /** The user opted out of the channel (recorded by an unsubscribe); stores without reporting omit it. */\n isSuppressed?(userId: string, channel: string): Promise<boolean>;\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. Stores whose schema the engine owns (D1) omit it.\n */\n checkSchema?(): Promise<string[]>;\n\n /* ---------------------------------- events --------------------------------- */\n /** Append the occurrence; false when its id was already in the log (nothing written). */\n insertEvent(input: EventInput): Promise<boolean>;\n /** Occurrences of `event` for the user, optionally only those within the window. */\n countEvents(userId: string, event: string, window?: EventWindow): Promise<number>;\n /** The payloads of those occurrences — the caller filters them with the shared evaluator. */\n listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]>;\n\n /* --------------------------------- profiles -------------------------------- */\n getProfile(userId: string): Promise<ProfileProps | undefined>;\n /**\n * Merge-patch `props` into the stored profile in one statement (a null\n * removes the property) and return the result. One statement, because a\n * read-modify-write loses one of two concurrent identifies.\n */\n mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps>;\n\n /** Every user the engine knows (a profile or an event): who the segment sweep evaluates. */\n listUserIds(): Promise<string[]>;\n\n /* ---------------------------------- devices --------------------------------- */\n /** The user's push devices, most recently registered first. */\n listDevices(userId: string): Promise<PushDevice[]>;\n /**\n * Register a device: one row per token, so a token registered again moves to the user who\n * registered it last (a phone signed into another account) and to the front of their list.\n */\n upsertDevice(device: DeviceInput): Promise<void>;\n /** Forget a token (reported dead by APNs / FCM); the user 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 sign-in\n * binds it again. The user it belonged to, if any.\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 replaceRoutes(bundle: BundleIR): Promise<void>;\n /**\n * The Worker version whose bundle the routes came from, when one put them there through\n * `replaceRoutesIfNewer` (a forced deploy leaves it as it was). Read from the database itself,\n * not a cache in front of it. Stores without it (D1) leave every deploy to `POST /bundle/deploy`.\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\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 { D1JourneyStore } from './d1';\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} from './tables';\nexport type { PostgresJourneyStoreOptions, SqlClientLike } from './postgres';\n"],"mappings":";;;;;;;;;;;AA0FA,IAAa,sBAAb,cAAyC,MAAM;CAGxB;CAFrB,OAAyB;CAEzB,YAAY,UAAsC;EAChD,MACE,4IAA4I,SAAS,KAAK,MAAM,GAClK;EAHmB,KAAA,WAAA;CAIrB;AACF"}
|
package/dist/store/index.d.cts
CHANGED
|
@@ -45,6 +45,15 @@ export interface SegmentTriggerRoute {
|
|
|
45
45
|
hash: string;
|
|
46
46
|
segment: string;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A Worker version, as Cloudflare's version metadata binding names it: the bundle compiled into
|
|
50
|
+
* that version goes live only over an older one (`replaceRoutesIfNewer`).
|
|
51
|
+
*/
|
|
52
|
+
export interface BundleVersion {
|
|
53
|
+
id: string;
|
|
54
|
+
/** When the version was created (epoch ms): later versions are newer. */
|
|
55
|
+
createdAt: number;
|
|
56
|
+
}
|
|
48
57
|
export interface EntryInput {
|
|
49
58
|
workflow: string;
|
|
50
59
|
userId: string;
|
|
@@ -263,6 +272,18 @@ export interface JourneyStore {
|
|
|
263
272
|
* are content-addressed and outlive the routes that point at them.
|
|
264
273
|
*/
|
|
265
274
|
replaceRoutes(bundle: BundleIR): Promise<void>;
|
|
275
|
+
/**
|
|
276
|
+
* The Worker version whose bundle the routes came from, when one put them there through
|
|
277
|
+
* `replaceRoutesIfNewer` (a forced deploy leaves it as it was). Read from the database itself,
|
|
278
|
+
* not a cache in front of it. Stores without it (D1) leave every deploy to `POST /bundle/deploy`.
|
|
279
|
+
*/
|
|
280
|
+
liveBundleVersion?(): Promise<BundleVersion | undefined>;
|
|
281
|
+
/**
|
|
282
|
+
* `replaceRoutes`, only when `version` is newer than the live one, recorded in the same
|
|
283
|
+
* transaction: two versions serving side by side during a rollout cannot leave the older one's
|
|
284
|
+
* routes in force. False, with nothing written, when a version as new or newer is live.
|
|
285
|
+
*/
|
|
286
|
+
replaceRoutesIfNewer?(bundle: BundleIR, version: BundleVersion): Promise<boolean>;
|
|
266
287
|
isSegmentMember(segment: string, userId: string): Promise<boolean>;
|
|
267
288
|
/** Record the membership, with the workflows its entry has to start (see segment_start). */
|
|
268
289
|
addSegmentMember(segment: string, userId: string, ts: number, workflows: readonly string[]): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.cts","names":[],"sources":["../../src/store/index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;YAoBY,eAAe,eAAe;iBAEzB;;EAEf;EACA;EACA;EACA;EACA,SAAS;;iBAGM;EACf;EACA;;EAEA,OAAO;;EAEP,QAAQ;;iBAGO;EACf;EACA;EACA;;iBAGe;EACf;EACA;EACA;EACA;EACA;;;;;;;YAQU;;iBAGK;EACf;EACA;;EAEA;EACA;;;iBAIe;EACf,OAAO;EACP,cAAc;;;;;;qBAOH,4BAA4B;WAGlB;WAFH;EAElB,YAAqB;;;iBAQN;EACf;EACA;EACA;EACA;EACA;;EAEA;EACA;EACA;;;iBAIe;EACf;EACA;EACA;;EAEA;IAAU;IAAkB;;;;iBAIb;EACf;EACA;;EAEA;EACA;;EAEA;EACA;EACA;;;;;;;;iBASe;EACf;EACA;;iBAGe;;EAEf,gBAAgB;IACd;IACA;IACA;IACA;MACE;EACJ,YAAY;IACV;IACA;IACA;IACA;IACA;MACE;EACJ,iBAAiB;IACf;IACA;IACA;IACA;IACA;IACA;MACE;;EAEJ,cAAc,SAAS,gBAAgB;EACvC,cACE,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;;EAEH,YAAY,mBAAmB,0BAA0B;;;;;;EAMzD,WACE,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;;;;;;;;;EASH,YACE,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;;EAEtC,kBAAkB,mBAAmB,aAAa;;EAElD,sBAAsB,mBAAmB,aAAa;;EAEtD,iBAAiB,aAAa;IAAU;IAAiB;;;iBAG1C;;WAEN,YAAY;;;;;EAMrB,eAAe,gBAAgB,aAAa;;EAE5C,mBAAmB;;EAEnB,iBAAiB,iBAAiB;;;;;;EAMlC,WAAW,iBAAiB;;EAG5B,cAAc,gBAAgB,kBAAkB;;;;;EAMhD,gBAAgB;;EAIhB,YAAY,OAAO,aAAa;;EAEhC,YAAY,gBAAgB,eAAe,SAAS,cAAc;;EAElE,kBACE,gBACA,eACA,SAAS,cACR,QAAQ;EAGX,WAAW,iBAAiB,QAAQ;;;;;;EAMpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;;EAG3D,eAAe;;EAIf,YAAY,iBAAiB,QAAQ;;;;;EAKrC,aAAa,QAAQ,cAAc;;EAEnC,aAAa,gBAAgB;;;;;EAK7B,aAAa,gBAAgB;EAG7B,oBAAoB,aAAa,QAAQ;;EAEzC,gBAAgB;IAAU;IAAY,WAAW;;EACjD,aAAa,gBAAgB,QAAQ;EACrC,uBAAuB,QAAQ;EAC/B,cAAc,UAAU,aAAa;EACrC,cAAc,sBAAsB,QAAQ;;;;;;;EAO5C,cAAc,QAAQ,WAAW;
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../../src/store/index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;YAoBY,eAAe,eAAe;iBAEzB;;EAEf;EACA;EACA;EACA;EACA,SAAS;;iBAGM;EACf;EACA;;EAEA,OAAO;;EAEP,QAAQ;;iBAGO;EACf;EACA;EACA;;;;;;iBAOe;EACf;;EAEA;;iBAGe;EACf;EACA;EACA;EACA;EACA;;;;;;;YAQU;;iBAGK;EACf;EACA;;EAEA;EACA;;;iBAIe;EACf,OAAO;EACP,cAAc;;;;;;qBAOH,4BAA4B;WAGlB;WAFH;EAElB,YAAqB;;;iBAQN;EACf;EACA;EACA;EACA;EACA;;EAEA;EACA;EACA;;;iBAIe;EACf;EACA;EACA;;EAEA;IAAU;IAAkB;;;;iBAIb;EACf;EACA;;EAEA;EACA;;EAEA;EACA;EACA;;;;;;;;iBASe;EACf;EACA;;iBAGe;;EAEf,gBAAgB;IACd;IACA;IACA;IACA;MACE;EACJ,YAAY;IACV;IACA;IACA;IACA;IACA;MACE;EACJ,iBAAiB;IACf;IACA;IACA;IACA;IACA;IACA;MACE;;EAEJ,cAAc,SAAS,gBAAgB;EACvC,cACE,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;;EAEH,YAAY,mBAAmB,0BAA0B;;;;;;EAMzD,WACE,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;;;;;;;;;EASH,YACE,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;;EAEtC,kBAAkB,mBAAmB,aAAa;;EAElD,sBAAsB,mBAAmB,aAAa;;EAEtD,iBAAiB,aAAa;IAAU;IAAiB;;;iBAG1C;;WAEN,YAAY;;;;;EAMrB,eAAe,gBAAgB,aAAa;;EAE5C,mBAAmB;;EAEnB,iBAAiB,iBAAiB;;;;;;EAMlC,WAAW,iBAAiB;;EAG5B,cAAc,gBAAgB,kBAAkB;;;;;EAMhD,gBAAgB;;EAIhB,YAAY,OAAO,aAAa;;EAEhC,YAAY,gBAAgB,eAAe,SAAS,cAAc;;EAElE,kBACE,gBACA,eACA,SAAS,cACR,QAAQ;EAGX,WAAW,iBAAiB,QAAQ;;;;;;EAMpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;;EAG3D,eAAe;;EAIf,YAAY,iBAAiB,QAAQ;;;;;EAKrC,aAAa,QAAQ,cAAc;;EAEnC,aAAa,gBAAgB;;;;;EAK7B,aAAa,gBAAgB;EAG7B,oBAAoB,aAAa,QAAQ;;EAEzC,gBAAgB;IAAU;IAAY,WAAW;;EACjD,aAAa,gBAAgB,QAAQ;EACrC,uBAAuB,QAAQ;EAC/B,cAAc,UAAU,aAAa;EACrC,cAAc,sBAAsB,QAAQ;;;;;;;EAO5C,cAAc,QAAQ,WAAW;;;;;;EAMjC,sBAAsB,QAAQ;;;;;;EAM9B,sBAAsB,QAAQ,UAAU,SAAS,gBAAgB;EAGjE,gBAAgB,iBAAiB,iBAAiB;;EAElD,iBACE,iBACA,gBACA,YACA,+BACC;;EAEH,qBAAqB,iBAAiB,iBAAiB;EACvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;;EAEzE,oBAAoB,iBAAiB,iBAAiB;;;;;;EAQtD,aAAa,OAAO,aAAa,QAAQ;EACzC,YAAY,qBAAqB;EACjC,eAAe,oBAAoB,iBAAiB;;EAIpD,gBAAgB,gBAAgB,gBAAgB;;EAEhD,UAAU,gBAAgB,gBAAgB,2BAA2B,aAAa;;EAElF,YAAY,iBAAiB"}
|
package/dist/store/index.d.mts
CHANGED
|
@@ -45,6 +45,15 @@ export interface SegmentTriggerRoute {
|
|
|
45
45
|
hash: string;
|
|
46
46
|
segment: string;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A Worker version, as Cloudflare's version metadata binding names it: the bundle compiled into
|
|
50
|
+
* that version goes live only over an older one (`replaceRoutesIfNewer`).
|
|
51
|
+
*/
|
|
52
|
+
export interface BundleVersion {
|
|
53
|
+
id: string;
|
|
54
|
+
/** When the version was created (epoch ms): later versions are newer. */
|
|
55
|
+
createdAt: number;
|
|
56
|
+
}
|
|
48
57
|
export interface EntryInput {
|
|
49
58
|
workflow: string;
|
|
50
59
|
userId: string;
|
|
@@ -263,6 +272,18 @@ export interface JourneyStore {
|
|
|
263
272
|
* are content-addressed and outlive the routes that point at them.
|
|
264
273
|
*/
|
|
265
274
|
replaceRoutes(bundle: BundleIR): Promise<void>;
|
|
275
|
+
/**
|
|
276
|
+
* The Worker version whose bundle the routes came from, when one put them there through
|
|
277
|
+
* `replaceRoutesIfNewer` (a forced deploy leaves it as it was). Read from the database itself,
|
|
278
|
+
* not a cache in front of it. Stores without it (D1) leave every deploy to `POST /bundle/deploy`.
|
|
279
|
+
*/
|
|
280
|
+
liveBundleVersion?(): Promise<BundleVersion | undefined>;
|
|
281
|
+
/**
|
|
282
|
+
* `replaceRoutes`, only when `version` is newer than the live one, recorded in the same
|
|
283
|
+
* transaction: two versions serving side by side during a rollout cannot leave the older one's
|
|
284
|
+
* routes in force. False, with nothing written, when a version as new or newer is live.
|
|
285
|
+
*/
|
|
286
|
+
replaceRoutesIfNewer?(bundle: BundleIR, version: BundleVersion): Promise<boolean>;
|
|
266
287
|
isSegmentMember(segment: string, userId: string): Promise<boolean>;
|
|
267
288
|
/** Record the membership, with the workflows its entry has to start (see segment_start). */
|
|
268
289
|
addSegmentMember(segment: string, userId: string, ts: number, workflows: readonly string[]): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/store/index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;YAoBY,eAAe,eAAe;iBAEzB;;EAEf;EACA;EACA;EACA;EACA,SAAS;;iBAGM;EACf;EACA;;EAEA,OAAO;;EAEP,QAAQ;;iBAGO;EACf;EACA;EACA;;iBAGe;EACf;EACA;EACA;EACA;EACA;;;;;;;YAQU;;iBAGK;EACf;EACA;;EAEA;EACA;;;iBAIe;EACf,OAAO;EACP,cAAc;;;;;;qBAOH,4BAA4B;WAGlB;WAFH;EAElB,YAAqB;;;iBAQN;EACf;EACA;EACA;EACA;EACA;;EAEA;EACA;EACA;;;iBAIe;EACf;EACA;EACA;;EAEA;IAAU;IAAkB;;;;iBAIb;EACf;EACA;;EAEA;EACA;;EAEA;EACA;EACA;;;;;;;;iBASe;EACf;EACA;;iBAGe;;EAEf,gBAAgB;IACd;IACA;IACA;IACA;MACE;EACJ,YAAY;IACV;IACA;IACA;IACA;IACA;MACE;EACJ,iBAAiB;IACf;IACA;IACA;IACA;IACA;IACA;MACE;;EAEJ,cAAc,SAAS,gBAAgB;EACvC,cACE,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;;EAEH,YAAY,mBAAmB,0BAA0B;;;;;;EAMzD,WACE,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;;;;;;;;;EASH,YACE,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;;EAEtC,kBAAkB,mBAAmB,aAAa;;EAElD,sBAAsB,mBAAmB,aAAa;;EAEtD,iBAAiB,aAAa;IAAU;IAAiB;;;iBAG1C;;WAEN,YAAY;;;;;EAMrB,eAAe,gBAAgB,aAAa;;EAE5C,mBAAmB;;EAEnB,iBAAiB,iBAAiB;;;;;;EAMlC,WAAW,iBAAiB;;EAG5B,cAAc,gBAAgB,kBAAkB;;;;;EAMhD,gBAAgB;;EAIhB,YAAY,OAAO,aAAa;;EAEhC,YAAY,gBAAgB,eAAe,SAAS,cAAc;;EAElE,kBACE,gBACA,eACA,SAAS,cACR,QAAQ;EAGX,WAAW,iBAAiB,QAAQ;;;;;;EAMpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;;EAG3D,eAAe;;EAIf,YAAY,iBAAiB,QAAQ;;;;;EAKrC,aAAa,QAAQ,cAAc;;EAEnC,aAAa,gBAAgB;;;;;EAK7B,aAAa,gBAAgB;EAG7B,oBAAoB,aAAa,QAAQ;;EAEzC,gBAAgB;IAAU;IAAY,WAAW;;EACjD,aAAa,gBAAgB,QAAQ;EACrC,uBAAuB,QAAQ;EAC/B,cAAc,UAAU,aAAa;EACrC,cAAc,sBAAsB,QAAQ;;;;;;;EAO5C,cAAc,QAAQ,WAAW;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/store/index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;YAoBY,eAAe,eAAe;iBAEzB;;EAEf;EACA;EACA;EACA;EACA,SAAS;;iBAGM;EACf;EACA;;EAEA,OAAO;;EAEP,QAAQ;;iBAGO;EACf;EACA;EACA;;;;;;iBAOe;EACf;;EAEA;;iBAGe;EACf;EACA;EACA;EACA;EACA;;;;;;;YAQU;;iBAGK;EACf;EACA;;EAEA;EACA;;;iBAIe;EACf,OAAO;EACP,cAAc;;;;;;qBAOH,4BAA4B;WAGlB;WAFH;EAElB,YAAqB;;;iBAQN;EACf;EACA;EACA;EACA;EACA;;EAEA;EACA;EACA;;;iBAIe;EACf;EACA;EACA;;EAEA;IAAU;IAAkB;;;;iBAIb;EACf;EACA;;EAEA;EACA;;EAEA;EACA;EACA;;;;;;;;iBASe;EACf;EACA;;iBAGe;;EAEf,gBAAgB;IACd;IACA;IACA;IACA;MACE;EACJ,YAAY;IACV;IACA;IACA;IACA;IACA;MACE;EACJ,iBAAiB;IACf;IACA;IACA;IACA;IACA;IACA;MACE;;EAEJ,cAAc,SAAS,gBAAgB;EACvC,cACE,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;;EAEH,YAAY,mBAAmB,0BAA0B;;;;;;EAMzD,WACE,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;;;;;;;;;EASH,YACE,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;;EAEtC,kBAAkB,mBAAmB,aAAa;;EAElD,sBAAsB,mBAAmB,aAAa;;EAEtD,iBAAiB,aAAa;IAAU;IAAiB;;;iBAG1C;;WAEN,YAAY;;;;;EAMrB,eAAe,gBAAgB,aAAa;;EAE5C,mBAAmB;;EAEnB,iBAAiB,iBAAiB;;;;;;EAMlC,WAAW,iBAAiB;;EAG5B,cAAc,gBAAgB,kBAAkB;;;;;EAMhD,gBAAgB;;EAIhB,YAAY,OAAO,aAAa;;EAEhC,YAAY,gBAAgB,eAAe,SAAS,cAAc;;EAElE,kBACE,gBACA,eACA,SAAS,cACR,QAAQ;EAGX,WAAW,iBAAiB,QAAQ;;;;;;EAMpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;;EAG3D,eAAe;;EAIf,YAAY,iBAAiB,QAAQ;;;;;EAKrC,aAAa,QAAQ,cAAc;;EAEnC,aAAa,gBAAgB;;;;;EAK7B,aAAa,gBAAgB;EAG7B,oBAAoB,aAAa,QAAQ;;EAEzC,gBAAgB;IAAU;IAAY,WAAW;;EACjD,aAAa,gBAAgB,QAAQ;EACrC,uBAAuB,QAAQ;EAC/B,cAAc,UAAU,aAAa;EACrC,cAAc,sBAAsB,QAAQ;;;;;;;EAO5C,cAAc,QAAQ,WAAW;;;;;;EAMjC,sBAAsB,QAAQ;;;;;;EAM9B,sBAAsB,QAAQ,UAAU,SAAS,gBAAgB;EAGjE,gBAAgB,iBAAiB,iBAAiB;;EAElD,iBACE,iBACA,gBACA,YACA,+BACC;;EAEH,qBAAqB,iBAAiB,iBAAiB;EACvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;;EAEzE,oBAAoB,iBAAiB,iBAAiB;;;;;;EAQtD,aAAa,OAAO,aAAa,QAAQ;EACzC,YAAY,qBAAqB;EACjC,eAAe,oBAAoB,iBAAiB;;EAIpD,gBAAgB,gBAAgB,gBAAgB;;EAEhD,UAAU,gBAAgB,gBAAgB,2BAA2B,aAAa;;EAElF,YAAY,iBAAiB"}
|
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 as a port. Everything the ingest router, the\n * runner and the fact source persist or look up goes through this interface,\n * so the storage engine is a deployment choice: D1 + KV (`D1JourneyStore`,\n * the original), Postgres (`PostgresJourneyStore`), or anything else that can\n * satisfy the contract below. Execution (durable steps, sleeps, wake-ups) is\n * 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}\n\nexport interface SegmentTriggerRoute {\n workflow: string;\n hash: string;\n segment: string;\n}\n\nexport interface EntryInput {\n workflow: string;\n userId: string;\n instanceId: string;\n hash: string;\n ts: number;\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 userId: string;\n token: string;\n /** `ios` (APNs) or `android` (FCM): which provider the token belongs to. */\n platform: string;\n ts: number;\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\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 sentAt: number;\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. A store that keeps it (Postgres) exposes it as `reporting`; the Worker then records it\n * and serves email tracking. 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\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 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 /** The email copy's recipient opted out of email: recorded on the copy and as a suppression. False for an unknown copy. */\n recordUnsubscribe(messageId: string, at: number): Promise<boolean>;\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\nexport interface JourneyStore {\n /** Reporting, when the store keeps it (Postgres); D1 stores none. */\n readonly reporting?: JourneyReporting;\n\n /**\n * Record that the user's data must go (a deleted account), before anything is erased: the\n * request outlives a failure to stop their instances, and the sweep retries it.\n */\n requestErasure(userId: string, at: number): Promise<void>;\n /** Users whose erasure was requested and not finished yet. */\n pendingErasures(): Promise<string[]>;\n /** The user's journey instances still running, by the entry ledger. */\n runningInstances(userId: string): Promise<string[]>;\n /**\n * Erase everything kept about the user — profile, devices, events, wake subscriptions, segment\n * memberships, suppressions, entries and reporting — and the erasure request last. Run only\n * once no instance of theirs runs any more, or one could write some of it back.\n */\n forgetUser(userId: string): Promise<void>;\n\n /** The user opted out of the channel (recorded by an unsubscribe); stores without reporting omit it. */\n isSuppressed?(userId: string, channel: string): Promise<boolean>;\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. Stores whose schema the engine owns (D1) omit it.\n */\n checkSchema?(): Promise<string[]>;\n\n /* ---------------------------------- events --------------------------------- */\n /** Append the occurrence; false when its id was already in the log (nothing written). */\n insertEvent(input: EventInput): Promise<boolean>;\n /** Occurrences of `event` for the user, optionally only those within the window. */\n countEvents(userId: string, event: string, window?: EventWindow): Promise<number>;\n /** The payloads of those occurrences — the caller filters them with the shared evaluator. */\n listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]>;\n\n /* --------------------------------- profiles -------------------------------- */\n getProfile(userId: string): Promise<ProfileProps | undefined>;\n /**\n * Merge-patch `props` into the stored profile in one statement (a null\n * removes the property) and return the result. One statement, because a\n * read-modify-write loses one of two concurrent identifies.\n */\n mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps>;\n\n /** Every user the engine knows (a profile or an event): who the segment sweep evaluates. */\n listUserIds(): Promise<string[]>;\n\n /* ---------------------------------- devices --------------------------------- */\n /** The user's push devices, most recently registered first. */\n listDevices(userId: string): Promise<PushDevice[]>;\n /**\n * Register a device: one row per token, so a token registered again moves to the user who\n * registered it last (a phone signed into another account) and to the front of their list.\n */\n upsertDevice(device: DeviceInput): Promise<void>;\n /** Forget a token (reported dead by APNs / FCM); the user 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 sign-in\n * binds it again. The user it belonged to, if any.\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 replaceRoutes(bundle: BundleIR): Promise<void>;\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\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 { D1JourneyStore } from './d1';\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} from './tables';\nexport type { PostgresJourneyStoreOptions, SqlClientLike } from './postgres';\n"],"mappings":";;;;;;;;;;AAgFA,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 as a port. Everything the ingest router, the\n * runner and the fact source persist or look up goes through this interface,\n * so the storage engine is a deployment choice: D1 + KV (`D1JourneyStore`,\n * the original), Postgres (`PostgresJourneyStore`), or anything else that can\n * satisfy the contract below. Execution (durable steps, sleeps, wake-ups) is\n * 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}\n\nexport interface SegmentTriggerRoute {\n workflow: string;\n hash: string;\n segment: string;\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\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 userId: string;\n token: string;\n /** `ios` (APNs) or `android` (FCM): which provider the token belongs to. */\n platform: string;\n ts: number;\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\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 sentAt: number;\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. A store that keeps it (Postgres) exposes it as `reporting`; the Worker then records it\n * and serves email tracking. 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\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 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 /** The email copy's recipient opted out of email: recorded on the copy and as a suppression. False for an unknown copy. */\n recordUnsubscribe(messageId: string, at: number): Promise<boolean>;\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\nexport interface JourneyStore {\n /** Reporting, when the store keeps it (Postgres); D1 stores none. */\n readonly reporting?: JourneyReporting;\n\n /**\n * Record that the user's data must go (a deleted account), before anything is erased: the\n * request outlives a failure to stop their instances, and the sweep retries it.\n */\n requestErasure(userId: string, at: number): Promise<void>;\n /** Users whose erasure was requested and not finished yet. */\n pendingErasures(): Promise<string[]>;\n /** The user's journey instances still running, by the entry ledger. */\n runningInstances(userId: string): Promise<string[]>;\n /**\n * Erase everything kept about the user — profile, devices, events, wake subscriptions, segment\n * memberships, suppressions, entries and reporting — and the erasure request last. Run only\n * once no instance of theirs runs any more, or one could write some of it back.\n */\n forgetUser(userId: string): Promise<void>;\n\n /** The user opted out of the channel (recorded by an unsubscribe); stores without reporting omit it. */\n isSuppressed?(userId: string, channel: string): Promise<boolean>;\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. Stores whose schema the engine owns (D1) omit it.\n */\n checkSchema?(): Promise<string[]>;\n\n /* ---------------------------------- events --------------------------------- */\n /** Append the occurrence; false when its id was already in the log (nothing written). */\n insertEvent(input: EventInput): Promise<boolean>;\n /** Occurrences of `event` for the user, optionally only those within the window. */\n countEvents(userId: string, event: string, window?: EventWindow): Promise<number>;\n /** The payloads of those occurrences — the caller filters them with the shared evaluator. */\n listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]>;\n\n /* --------------------------------- profiles -------------------------------- */\n getProfile(userId: string): Promise<ProfileProps | undefined>;\n /**\n * Merge-patch `props` into the stored profile in one statement (a null\n * removes the property) and return the result. One statement, because a\n * read-modify-write loses one of two concurrent identifies.\n */\n mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps>;\n\n /** Every user the engine knows (a profile or an event): who the segment sweep evaluates. */\n listUserIds(): Promise<string[]>;\n\n /* ---------------------------------- devices --------------------------------- */\n /** The user's push devices, most recently registered first. */\n listDevices(userId: string): Promise<PushDevice[]>;\n /**\n * Register a device: one row per token, so a token registered again moves to the user who\n * registered it last (a phone signed into another account) and to the front of their list.\n */\n upsertDevice(device: DeviceInput): Promise<void>;\n /** Forget a token (reported dead by APNs / FCM); the user 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 sign-in\n * binds it again. The user it belonged to, if any.\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 replaceRoutes(bundle: BundleIR): Promise<void>;\n /**\n * The Worker version whose bundle the routes came from, when one put them there through\n * `replaceRoutesIfNewer` (a forced deploy leaves it as it was). Read from the database itself,\n * not a cache in front of it. Stores without it (D1) leave every deploy to `POST /bundle/deploy`.\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\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 { D1JourneyStore } from './d1';\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} from './tables';\nexport type { PostgresJourneyStoreOptions, SqlClientLike } from './postgres';\n"],"mappings":";;;;;;;;;;AA0FA,IAAa,sBAAb,cAAyC,MAAM;CAGxB;CAFrB,OAAyB;CAEzB,YAAY,UAAsC;EAChD,MACE,4IAA4I,SAAS,KAAK,MAAM,GAClK;EAHmB,KAAA,WAAA;CAIrB;AACF"}
|
package/dist/store/postgres.cjs
CHANGED
|
@@ -203,29 +203,54 @@ var PostgresJourneyStore = class {
|
|
|
203
203
|
return row === void 0 ? void 0 : require_ir.WorkflowIR.parse(asJson(row.ir));
|
|
204
204
|
}
|
|
205
205
|
replaceRoutes(bundle) {
|
|
206
|
+
return this.sql.transaction((tx) => this.swapRoutes(tx, bundle));
|
|
207
|
+
}
|
|
208
|
+
async liveBundleVersion() {
|
|
209
|
+
const row = (await this.sql.transaction((tx) => tx.query(`SELECT version_id, (extract(epoch FROM version_created_at) * 1000)::double precision AS created_at
|
|
210
|
+
FROM ${this.t.deployment} WHERE scope = 'routes'`))).at(0);
|
|
211
|
+
return row === void 0 ? void 0 : {
|
|
212
|
+
id: row.version_id,
|
|
213
|
+
createdAt: Number(row.created_at)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
replaceRoutesIfNewer(bundle, version) {
|
|
206
217
|
return this.sql.transaction(async (tx) => {
|
|
207
|
-
await tx.query(`
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
workflow.trigger.where === void 0 ? null : JSON.stringify(workflow.trigger.where),
|
|
220
|
-
workflow.trigger.filter === void 0 ? null : JSON.stringify(workflow.trigger.filter)
|
|
221
|
-
]);
|
|
222
|
-
else if (workflow.trigger.type === "segment") await tx.query(`INSERT INTO ${this.t.segmentTrigger} (workflow, hash, segment) VALUES ($1, $2, $3)`, [
|
|
223
|
-
workflow.id,
|
|
224
|
-
workflow.contentHash,
|
|
225
|
-
workflow.trigger.segment
|
|
226
|
-
]);
|
|
218
|
+
if ((await tx.query(`INSERT INTO ${this.t.deployment} AS live (scope, version_id, version_created_at, applied_at)
|
|
219
|
+
VALUES ('routes', $1, ${TS("$2")}, ${TS("$3")})
|
|
220
|
+
ON CONFLICT (scope) DO UPDATE SET version_id = EXCLUDED.version_id,
|
|
221
|
+
version_created_at = EXCLUDED.version_created_at, applied_at = EXCLUDED.applied_at
|
|
222
|
+
WHERE live.version_created_at < EXCLUDED.version_created_at
|
|
223
|
+
RETURNING scope`, [
|
|
224
|
+
version.id,
|
|
225
|
+
version.createdAt,
|
|
226
|
+
Date.now()
|
|
227
|
+
])).length === 0) return false;
|
|
228
|
+
await this.swapRoutes(tx, bundle);
|
|
229
|
+
return true;
|
|
227
230
|
});
|
|
228
231
|
}
|
|
232
|
+
async swapRoutes(tx, bundle) {
|
|
233
|
+
await tx.query(`DELETE FROM ${this.t.trigger}`);
|
|
234
|
+
await tx.query(`DELETE FROM ${this.t.segmentTrigger}`);
|
|
235
|
+
await tx.query(`DELETE FROM ${this.t.segment}`);
|
|
236
|
+
for (const segment of bundle.segments) await tx.query(`INSERT INTO ${this.t.segment} (id, condition, hash) VALUES ($1, $2::text::jsonb, $3)`, [
|
|
237
|
+
segment.id,
|
|
238
|
+
JSON.stringify(segment.condition),
|
|
239
|
+
segment.contentHash
|
|
240
|
+
]);
|
|
241
|
+
for (const workflow of bundle.workflows) if (workflow.trigger.type === "event") await tx.query(`INSERT INTO ${this.t.trigger} (workflow, hash, event, "where", filter) VALUES ($1, $2, $3, $4::text::jsonb, $5::text::jsonb)`, [
|
|
242
|
+
workflow.id,
|
|
243
|
+
workflow.contentHash,
|
|
244
|
+
workflow.trigger.event,
|
|
245
|
+
workflow.trigger.where === void 0 ? null : JSON.stringify(workflow.trigger.where),
|
|
246
|
+
workflow.trigger.filter === void 0 ? null : JSON.stringify(workflow.trigger.filter)
|
|
247
|
+
]);
|
|
248
|
+
else if (workflow.trigger.type === "segment") await tx.query(`INSERT INTO ${this.t.segmentTrigger} (workflow, hash, segment) VALUES ($1, $2, $3)`, [
|
|
249
|
+
workflow.id,
|
|
250
|
+
workflow.contentHash,
|
|
251
|
+
workflow.trigger.segment
|
|
252
|
+
]);
|
|
253
|
+
}
|
|
229
254
|
async isSegmentMember(segment, userId) {
|
|
230
255
|
return (await this.sql.query(`SELECT 1 AS x FROM ${this.t.segmentMember} WHERE segment = $1 AND user_id = $2`, [segment, userId])).length > 0;
|
|
231
256
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres.cjs","names":["TABLES","PostgresReporting","columnName","uuidv7","ConditionIR","WorkflowIR"],"sources":["../../src/store/postgres.ts"],"sourcesContent":["import type { PushDevice } from '../engine/ports';\nimport { type BundleIR, ConditionIR, WorkflowIR } from '../ir';\nimport type {\n DeviceInput,\n EntryInput,\n EntryOutcome,\n EventInput,\n EventWindow,\n JourneyReporting,\n JourneyStore,\n ProfileProps,\n SegmentTriggerRoute,\n TriggerRoute,\n} from './index';\nimport { PostgresReporting } from './postgres-reporting';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport { type ColumnType, DEFAULT_SCHEMA, TABLES, type TableKey, columnName } from './tables';\n\nexport { DEFAULT_SCHEMA } from './tables';\nexport { type SqlClientLike, uuidv7 } from './sql';\n\nexport interface PostgresJourneyStoreOptions {\n /**\n * The Postgres schema the engine's tables live in — `workflow` by default, the config's\n * `database.schema` under journeyWorker. A schema of its own keeps the tables apart from the\n * app's. The host creates them with its own migrations, from the drizzle file\n * `renderDrizzleSchema` generates (or the plain `renderPostgresDdl`).\n */\n schema?: string;\n}\n\n/** information_schema's udt_name per column type. */\nconst UDT: Record<ColumnType, string> = {\n uuid: 'uuid',\n text: 'text',\n jsonb: 'jsonb',\n timestamptz: 'timestamptz',\n integer: 'int4',\n boolean: 'bool',\n};\n\n/** Epoch milliseconds → timestamptz, so the tables keep the database's native type. */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\n\nfunction asJson<T>(value: unknown): T {\n return (typeof value === 'string' ? JSON.parse(value) : value) as T;\n}\n\n/**\n * Journey data plane on Postgres. Same contract as D1JourneyStore, native\n * types where Postgres has them (jsonb, timestamptz), and the concurrency\n * guarantees come from the database: ON CONFLICT for the once policy and\n * idempotent subscriptions, a transaction for the route swap, a jsonb merge\n * for /identify. The tables are declared in tables.ts.\n */\nexport class PostgresJourneyStore implements JourneyStore {\n private readonly t: Record<TableKey, string>;\n private readonly schema: string;\n readonly reporting: JourneyReporting;\n\n constructor(\n private readonly sql: SqlClientLike,\n options: PostgresJourneyStoreOptions = {}\n ) {\n this.schema = options.schema ?? DEFAULT_SCHEMA;\n const qualify = (name: string) => `\"${this.schema}\".\"${name}\"`;\n this.t = Object.fromEntries(\n (Object.keys(TABLES) as TableKey[]).map((key) => [key, qualify(TABLES[key].name)])\n ) as Record<TableKey, string>;\n this.reporting = new PostgresReporting(sql, this.t);\n }\n\n /**\n * What the live database lacks against tables.ts: missing tables, missing columns, a column\n * of another type or nullability. Read-only — the engine never migrates the host's database; a\n * deploy fails with these problems listed, and the fix is to regenerate the drizzle file and\n * run the host's migrations.\n */\n async checkSchema(): Promise<string[]> {\n // In a transaction, so Hyperdrive answers from the database, not from its read cache — a\n // deploy right after the migration must see the new columns\n const rows = await this.sql.transaction((tx) =>\n tx.query<{\n table_name: string;\n column_name: string;\n udt_name: string;\n is_nullable: string;\n }>(\n `SELECT table_name, column_name, udt_name, is_nullable FROM information_schema.columns WHERE table_schema = $1`,\n [this.schema]\n )\n );\n const live = new Map(rows.map((row) => [`${row.table_name}.${row.column_name}`, row]));\n const tables = new Set(rows.map((row) => row.table_name));\n const problems: string[] = [];\n for (const spec of Object.values(TABLES)) {\n const where = `\"${this.schema}\".\"${spec.name}\"`;\n if (!tables.has(spec.name)) {\n problems.push(`table ${where} is missing`);\n continue;\n }\n for (const [key, column] of Object.entries(spec.columns)) {\n const name = columnName(key);\n const row = live.get(`${spec.name}.${name}`);\n if (row === undefined) problems.push(`column ${where}.\"${name}\" is missing`);\n else if (row.udt_name !== UDT[column.type])\n problems.push(\n `column ${where}.\"${name}\" is ${row.udt_name}, expected ${UDT[column.type]}`\n );\n else if ((row.is_nullable === 'YES') !== (column.nullable === true))\n problems.push(\n `column ${where}.\"${name}\" should be ${column.nullable ? 'nullable' : 'NOT NULL'}`\n );\n }\n }\n return problems;\n }\n\n async insertEvent(input: EventInput): Promise<boolean> {\n // The row key stays a UUID v7 (the house rule for every table); the\n // sender's occurrence id lives in event_id, whose unique index is what\n // makes a second delivery a no-op. ON CONFLICT DO NOTHING + RETURNING\n // reports which of the two happened without a second round trip.\n const rows = await this.sql.query(\n `INSERT INTO ${this.t.event} (id, event_id, user_id, name, ts, payload)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6::text::jsonb)\n ON CONFLICT (event_id) DO NOTHING RETURNING id`,\n [\n uuidv7(input.ts),\n input.id,\n input.userId,\n input.event,\n input.ts,\n JSON.stringify(input.payload),\n ]\n );\n return rows.length > 0;\n }\n\n /** The event rows of one user and name, within the window's bounds (both inclusive). */\n private eventWindow(userId: string, event: string, window?: EventWindow) {\n const params: unknown[] = [userId, event];\n let where = 'user_id = $1 AND name = $2';\n if (window?.sinceMs !== undefined) {\n params.push(window.sinceMs);\n where += ` AND ts >= ${TS(`$${params.length}`)}`;\n }\n if (window?.untilMs !== undefined) {\n params.push(window.untilMs);\n where += ` AND ts <= ${TS(`$${params.length}`)}`;\n }\n return { where, params };\n }\n\n async countEvents(userId: string, event: string, window?: EventWindow): Promise<number> {\n const { where, params } = this.eventWindow(userId, event, window);\n const rows = await this.sql.query<{ c: number | string }>(\n `SELECT count(*)::int AS c FROM ${this.t.event} WHERE ${where}`,\n params\n );\n return Number(rows.at(0)?.c ?? 0);\n }\n\n async listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]> {\n const { where, params } = this.eventWindow(userId, event, window);\n const rows = await this.sql.query<{ payload: unknown }>(\n `SELECT payload FROM ${this.t.event} WHERE ${where}`,\n params\n );\n return rows.map((row) => asJson<Record<string, unknown>>(row.payload));\n }\n\n async getProfile(userId: string): Promise<ProfileProps | undefined> {\n const rows = await this.sql.query<{ props: unknown }>(\n `SELECT props FROM ${this.t.profile} WHERE user_id = $1`,\n [userId]\n );\n const row = rows.at(0);\n return row === undefined ? undefined : asJson<ProfileProps>(row.props);\n }\n\n async mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps> {\n // RFC 7386 merge-patch in one statement: jsonb `||` overwrites key by key\n // and jsonb_strip_nulls then drops the keys the patch set to null (the\n // API's only way to unset a property). Atomic in the database, so two\n // concurrent identifies cannot lose each other's keys.\n const rows = await this.sql.query<{ props: unknown }>(\n `INSERT INTO ${this.t.profile} (user_id, props) VALUES ($1, jsonb_strip_nulls($2::text::jsonb))\n ON CONFLICT (user_id) DO UPDATE SET props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb)\n RETURNING props`,\n // EXCLUDED.props would be the stripped insert value, so the update merges the raw patch again\n [userId, JSON.stringify(props), JSON.stringify(props)]\n );\n return asJson<ProfileProps>(rows.at(0)?.props ?? {});\n }\n\n async listUserIds(): Promise<string[]> {\n const rows = await this.sql.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.profile} UNION SELECT DISTINCT user_id FROM ${this.t.event}`\n );\n return rows.map((row) => row.user_id);\n }\n\n listDevices(userId: string): Promise<PushDevice[]> {\n return this.sql.query<PushDevice>(\n `SELECT token, platform FROM ${this.t.device} WHERE user_id = $1 ORDER BY ts DESC`,\n [userId]\n );\n }\n\n async upsertDevice(device: DeviceInput): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.device} (token, user_id, platform, ts) VALUES ($1, $2, $3, ${TS('$4')})\n ON CONFLICT (token) DO UPDATE SET user_id = EXCLUDED.user_id, platform = EXCLUDED.platform, ts = EXCLUDED.ts`,\n [device.token, device.userId, device.platform, device.ts]\n );\n }\n\n async requestErasure(userId: string, at: number): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.erasure} (user_id, requested_at) VALUES ($1, ${TS('$2')})\n ON CONFLICT DO NOTHING`,\n [userId, at]\n );\n }\n\n async pendingErasures(): Promise<string[]> {\n const rows = await this.sql.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.erasure} ORDER BY requested_at`\n );\n return rows.map((row) => row.user_id);\n }\n\n async runningInstances(userId: string): Promise<string[]> {\n const rows = await this.sql.query<{ instance_id: string }>(\n `SELECT instance_id FROM ${this.t.entry} WHERE user_id = $1 AND status = 'running'`,\n [userId]\n );\n return rows.map((row) => row.instance_id);\n }\n\n async forgetUser(userId: string): Promise<void> {\n await this.sql.transaction(async (tx) => {\n // Children before the copies they belong to\n for (const child of [this.t.messageOpen, this.t.messageClick, this.t.messageLink]) {\n await tx.query(\n `DELETE FROM ${child} WHERE message_id IN (SELECT id FROM ${this.t.message} WHERE user_id = $1)`,\n [userId]\n );\n }\n for (const table of [\n this.t.message,\n this.t.instance,\n this.t.entry,\n this.t.subscription,\n this.t.segmentStart,\n this.t.segmentMember,\n this.t.suppression,\n this.t.device,\n this.t.event,\n this.t.profile,\n // The request goes with the data it asked to erase\n this.t.erasure,\n ]) {\n await tx.query(`DELETE FROM ${table} WHERE user_id = $1`, [userId]);\n }\n });\n }\n\n async isSuppressed(userId: string, channel: string): Promise<boolean> {\n const rows = await this.sql.query(\n `SELECT 1 AS x FROM ${this.t.suppression} WHERE user_id = $1 AND channel = $2`,\n [userId, channel]\n );\n return rows.length > 0;\n }\n\n async removeDevice(token: string): Promise<string | undefined> {\n const rows = await this.sql.query<{ user_id: string | null }>(\n `DELETE FROM ${this.t.device} WHERE token = $1 RETURNING user_id`,\n [token]\n );\n return rows[0]?.user_id ?? undefined;\n }\n\n async unbindDevice(token: string): Promise<string | undefined> {\n // The owner as it was before the update: RETURNING alone would only show the null\n const rows = await this.sql.query<{ user_id: string | null }>(\n `WITH old AS (SELECT token, user_id FROM ${this.t.device} WHERE token = $1 FOR UPDATE)\n UPDATE ${this.t.device} d SET user_id = NULL FROM old WHERE d.token = old.token\n RETURNING old.user_id`,\n [token]\n );\n return rows[0]?.user_id ?? undefined;\n }\n\n async listSegments(): Promise<{ id: string; condition: ConditionIR }[]> {\n const rows = await this.sql.query<{ id: string; condition: unknown }>(\n `SELECT id, condition FROM ${this.t.segment} ORDER BY id`\n );\n return rows.map((row) => ({\n id: row.id,\n condition: ConditionIR.parse(asJson(row.condition)),\n }));\n }\n\n async getSegmentCondition(id: string): Promise<ConditionIR | undefined> {\n const rows = await this.sql.query<{ condition: unknown }>(\n `SELECT condition FROM ${this.t.segment} WHERE id = $1`,\n [id]\n );\n const row = rows.at(0);\n return row === undefined ? undefined : ConditionIR.parse(asJson(row.condition));\n }\n\n async findTriggers(event: string): Promise<TriggerRoute[]> {\n const rows = await this.sql.query<{\n workflow: string;\n hash: string;\n where_clause: unknown;\n filter: unknown;\n }>(\n `SELECT workflow, hash, \"where\" AS where_clause, filter FROM ${this.t.trigger} WHERE event = $1`,\n [event]\n );\n return rows.map((row) => ({\n workflow: row.workflow,\n hash: row.hash,\n where: row.where_clause === null ? null : ConditionIR.parse(asJson(row.where_clause)),\n filter: row.filter === null ? null : ConditionIR.parse(asJson(row.filter)),\n }));\n }\n\n listSegmentTriggers(): Promise<SegmentTriggerRoute[]> {\n return this.sql.query<SegmentTriggerRoute>(\n `SELECT workflow, hash, segment FROM ${this.t.segmentTrigger}`\n );\n }\n\n async putWorkflowIR(workflow: WorkflowIR): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.ir} (content_hash, ir) VALUES ($1, $2::text::jsonb)\n ON CONFLICT (content_hash) DO UPDATE SET ir = EXCLUDED.ir`,\n [workflow.contentHash, JSON.stringify(workflow)]\n );\n }\n\n async getWorkflowIR(contentHash: string): Promise<WorkflowIR | undefined> {\n const rows = await this.sql.query<{ ir: unknown }>(\n `SELECT ir FROM ${this.t.ir} WHERE content_hash = $1`,\n [contentHash]\n );\n const row = rows.at(0);\n return row === undefined ? undefined : WorkflowIR.parse(asJson(row.ir));\n }\n\n replaceRoutes(bundle: BundleIR): Promise<void> {\n return this.sql.transaction(async (tx) => {\n await tx.query(`DELETE FROM ${this.t.trigger}`);\n await tx.query(`DELETE FROM ${this.t.segmentTrigger}`);\n await tx.query(`DELETE FROM ${this.t.segment}`);\n for (const segment of bundle.segments) {\n await tx.query(\n `INSERT INTO ${this.t.segment} (id, condition, hash) VALUES ($1, $2::text::jsonb, $3)`,\n [segment.id, JSON.stringify(segment.condition), segment.contentHash]\n );\n }\n for (const workflow of bundle.workflows) {\n if (workflow.trigger.type === 'event') {\n await tx.query(\n `INSERT INTO ${this.t.trigger} (workflow, hash, event, \"where\", filter) VALUES ($1, $2, $3, $4::text::jsonb, $5::text::jsonb)`,\n [\n workflow.id,\n workflow.contentHash,\n workflow.trigger.event,\n workflow.trigger.where === undefined ? null : JSON.stringify(workflow.trigger.where),\n workflow.trigger.filter === undefined\n ? null\n : JSON.stringify(workflow.trigger.filter),\n ]\n );\n } else if (workflow.trigger.type === 'segment') {\n await tx.query(\n `INSERT INTO ${this.t.segmentTrigger} (workflow, hash, segment) VALUES ($1, $2, $3)`,\n [workflow.id, workflow.contentHash, workflow.trigger.segment]\n );\n }\n }\n });\n }\n\n async isSegmentMember(segment: string, userId: string): Promise<boolean> {\n const rows = await this.sql.query(\n `SELECT 1 AS x FROM ${this.t.segmentMember} WHERE segment = $1 AND user_id = $2`,\n [segment, userId]\n );\n return rows.length > 0;\n }\n\n async addSegmentMember(\n segment: string,\n userId: string,\n ts: number,\n workflows: readonly string[]\n ): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await tx.query(\n `INSERT INTO ${this.t.segmentMember} (segment, user_id, ts) VALUES ($1, $2, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [segment, userId, ts]\n );\n for (const workflow of workflows) {\n await tx.query(\n `INSERT INTO ${this.t.segmentStart} (segment, workflow, user_id, ts)\n VALUES ($1, $2, $3, ${TS('$4')}) ON CONFLICT DO NOTHING`,\n [segment, workflow, userId, ts]\n );\n }\n });\n }\n\n async pendingSegmentStarts(segment: string, userId: string): Promise<string[]> {\n const rows = await this.sql.query<{ workflow: string }>(\n `SELECT workflow FROM ${this.t.segmentStart} WHERE segment = $1 AND user_id = $2`,\n [segment, userId]\n );\n return rows.map((row) => row.workflow);\n }\n\n async completeSegmentStart(segment: string, userId: string, workflow: string): Promise<void> {\n await this.sql.query(\n `DELETE FROM ${this.t.segmentStart} WHERE segment = $1 AND user_id = $2 AND workflow = $3`,\n [segment, userId, workflow]\n );\n }\n\n async removeSegmentMember(segment: string, userId: string): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await tx.query(`DELETE FROM ${this.t.segmentStart} WHERE segment = $1 AND user_id = $2`, [\n segment,\n userId,\n ]);\n await tx.query(`DELETE FROM ${this.t.segmentMember} WHERE segment = $1 AND user_id = $2`, [\n segment,\n userId,\n ]);\n });\n }\n\n async enterJourney(entry: EntryInput): Promise<EntryOutcome> {\n // The (workflow, user_id) primary key makes the once policy atomic:\n // concurrent ingests race on the insert and exactly one wins.\n const inserted = await this.sql.query(\n `INSERT INTO ${this.t.entry} (workflow, user_id, instance_id, hash, status, ts)\n VALUES ($1, $2, $3, $4, 'running', ${TS('$5')})\n ON CONFLICT (workflow, user_id) DO NOTHING RETURNING instance_id`,\n [entry.workflow, entry.userId, entry.instanceId, entry.hash, entry.ts]\n );\n if (inserted.length > 0) return 'entered';\n\n // An existing row blocks the entry unless it records an instance that died:\n // 'failed' is reclaimed rather than barring the user forever over one\n // outage. The status test is inside the UPDATE, so of two concurrent\n // retries exactly one gets a row back.\n const claimed = await this.sql.query(\n `UPDATE ${this.t.entry} SET instance_id = $1, hash = $2, status = 'running', ts = ${TS('$3')}\n WHERE workflow = $4 AND user_id = $5 AND status = 'failed' RETURNING instance_id`,\n [entry.instanceId, entry.hash, entry.ts, entry.workflow, entry.userId]\n );\n return claimed.length > 0 ? 'reclaimed' : null;\n }\n\n async removeEntry(instanceId: string): Promise<void> {\n await this.sql.query(`DELETE FROM ${this.t.entry} WHERE instance_id = $1`, [instanceId]);\n }\n\n async setEntryStatus(instanceId: string, status: string): Promise<void> {\n await this.sql.query(`UPDATE ${this.t.entry} SET status = $1 WHERE instance_id = $2`, [\n status,\n instanceId,\n ]);\n }\n\n async findWakeHandles(userId: string, event: string): Promise<string[]> {\n const rows = await this.sql.query<{ handle: string }>(\n `SELECT DISTINCT wake_handle AS handle FROM ${this.t.subscription} WHERE user_id = $1 AND event = $2`,\n [userId, event]\n );\n return rows.map((row) => row.handle);\n }\n\n async subscribe(\n handle: string,\n userId: string,\n events: readonly string[],\n ts: number\n ): Promise<void> {\n for (const event of events) {\n await this.sql.query(\n `INSERT INTO ${this.t.subscription} (user_id, event, wake_handle, ts) VALUES ($1, $2, $3, ${TS('$4')}) ON CONFLICT DO NOTHING`,\n [userId, event, handle, ts]\n );\n }\n }\n\n async unsubscribe(handle: string): Promise<void> {\n await this.sql.query(`DELETE FROM ${this.t.subscription} WHERE wake_handle = $1`, [handle]);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"postgres.cjs","names":["TABLES","PostgresReporting","columnName","uuidv7","ConditionIR","WorkflowIR"],"sources":["../../src/store/postgres.ts"],"sourcesContent":["import type { PushDevice } from '../engine/ports';\nimport { type BundleIR, ConditionIR, WorkflowIR } from '../ir';\nimport type {\n BundleVersion,\n DeviceInput,\n EntryInput,\n EntryOutcome,\n EventInput,\n EventWindow,\n JourneyReporting,\n JourneyStore,\n ProfileProps,\n SegmentTriggerRoute,\n TriggerRoute,\n} from './index';\nimport { PostgresReporting } from './postgres-reporting';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport { type ColumnType, DEFAULT_SCHEMA, TABLES, type TableKey, columnName } from './tables';\n\nexport { DEFAULT_SCHEMA } from './tables';\nexport { type SqlClientLike, uuidv7 } from './sql';\n\nexport interface PostgresJourneyStoreOptions {\n /**\n * The Postgres schema the engine's tables live in — `workflow` by default, the config's\n * `database.schema` under journeyWorker. A schema of its own keeps the tables apart from the\n * app's. The host creates them with its own migrations, from the drizzle file\n * `renderDrizzleSchema` generates (or the plain `renderPostgresDdl`).\n */\n schema?: string;\n}\n\n/** information_schema's udt_name per column type. */\nconst UDT: Record<ColumnType, string> = {\n uuid: 'uuid',\n text: 'text',\n jsonb: 'jsonb',\n timestamptz: 'timestamptz',\n integer: 'int4',\n boolean: 'bool',\n};\n\n/** Epoch milliseconds → timestamptz, so the tables keep the database's native type. */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\n\nfunction asJson<T>(value: unknown): T {\n return (typeof value === 'string' ? JSON.parse(value) : value) as T;\n}\n\n/**\n * Journey data plane on Postgres. Same contract as D1JourneyStore, native\n * types where Postgres has them (jsonb, timestamptz), and the concurrency\n * guarantees come from the database: ON CONFLICT for the once policy and\n * idempotent subscriptions, a transaction for the route swap, a jsonb merge\n * for /identify. The tables are declared in tables.ts.\n */\nexport class PostgresJourneyStore implements JourneyStore {\n private readonly t: Record<TableKey, string>;\n private readonly schema: string;\n readonly reporting: JourneyReporting;\n\n constructor(\n private readonly sql: SqlClientLike,\n options: PostgresJourneyStoreOptions = {}\n ) {\n this.schema = options.schema ?? DEFAULT_SCHEMA;\n const qualify = (name: string) => `\"${this.schema}\".\"${name}\"`;\n this.t = Object.fromEntries(\n (Object.keys(TABLES) as TableKey[]).map((key) => [key, qualify(TABLES[key].name)])\n ) as Record<TableKey, string>;\n this.reporting = new PostgresReporting(sql, this.t);\n }\n\n /**\n * What the live database lacks against tables.ts: missing tables, missing columns, a column\n * of another type or nullability. Read-only — the engine never migrates the host's database; a\n * deploy fails with these problems listed, and the fix is to regenerate the drizzle file and\n * run the host's migrations.\n */\n async checkSchema(): Promise<string[]> {\n // In a transaction, so Hyperdrive answers from the database, not from its read cache — a\n // deploy right after the migration must see the new columns\n const rows = await this.sql.transaction((tx) =>\n tx.query<{\n table_name: string;\n column_name: string;\n udt_name: string;\n is_nullable: string;\n }>(\n `SELECT table_name, column_name, udt_name, is_nullable FROM information_schema.columns WHERE table_schema = $1`,\n [this.schema]\n )\n );\n const live = new Map(rows.map((row) => [`${row.table_name}.${row.column_name}`, row]));\n const tables = new Set(rows.map((row) => row.table_name));\n const problems: string[] = [];\n for (const spec of Object.values(TABLES)) {\n const where = `\"${this.schema}\".\"${spec.name}\"`;\n if (!tables.has(spec.name)) {\n problems.push(`table ${where} is missing`);\n continue;\n }\n for (const [key, column] of Object.entries(spec.columns)) {\n const name = columnName(key);\n const row = live.get(`${spec.name}.${name}`);\n if (row === undefined) problems.push(`column ${where}.\"${name}\" is missing`);\n else if (row.udt_name !== UDT[column.type])\n problems.push(\n `column ${where}.\"${name}\" is ${row.udt_name}, expected ${UDT[column.type]}`\n );\n else if ((row.is_nullable === 'YES') !== (column.nullable === true))\n problems.push(\n `column ${where}.\"${name}\" should be ${column.nullable ? 'nullable' : 'NOT NULL'}`\n );\n }\n }\n return problems;\n }\n\n async insertEvent(input: EventInput): Promise<boolean> {\n // The row key stays a UUID v7 (the house rule for every table); the\n // sender's occurrence id lives in event_id, whose unique index is what\n // makes a second delivery a no-op. ON CONFLICT DO NOTHING + RETURNING\n // reports which of the two happened without a second round trip.\n const rows = await this.sql.query(\n `INSERT INTO ${this.t.event} (id, event_id, user_id, name, ts, payload)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6::text::jsonb)\n ON CONFLICT (event_id) DO NOTHING RETURNING id`,\n [\n uuidv7(input.ts),\n input.id,\n input.userId,\n input.event,\n input.ts,\n JSON.stringify(input.payload),\n ]\n );\n return rows.length > 0;\n }\n\n /** The event rows of one user and name, within the window's bounds (both inclusive). */\n private eventWindow(userId: string, event: string, window?: EventWindow) {\n const params: unknown[] = [userId, event];\n let where = 'user_id = $1 AND name = $2';\n if (window?.sinceMs !== undefined) {\n params.push(window.sinceMs);\n where += ` AND ts >= ${TS(`$${params.length}`)}`;\n }\n if (window?.untilMs !== undefined) {\n params.push(window.untilMs);\n where += ` AND ts <= ${TS(`$${params.length}`)}`;\n }\n return { where, params };\n }\n\n async countEvents(userId: string, event: string, window?: EventWindow): Promise<number> {\n const { where, params } = this.eventWindow(userId, event, window);\n const rows = await this.sql.query<{ c: number | string }>(\n `SELECT count(*)::int AS c FROM ${this.t.event} WHERE ${where}`,\n params\n );\n return Number(rows.at(0)?.c ?? 0);\n }\n\n async listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]> {\n const { where, params } = this.eventWindow(userId, event, window);\n const rows = await this.sql.query<{ payload: unknown }>(\n `SELECT payload FROM ${this.t.event} WHERE ${where}`,\n params\n );\n return rows.map((row) => asJson<Record<string, unknown>>(row.payload));\n }\n\n async getProfile(userId: string): Promise<ProfileProps | undefined> {\n const rows = await this.sql.query<{ props: unknown }>(\n `SELECT props FROM ${this.t.profile} WHERE user_id = $1`,\n [userId]\n );\n const row = rows.at(0);\n return row === undefined ? undefined : asJson<ProfileProps>(row.props);\n }\n\n async mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps> {\n // RFC 7386 merge-patch in one statement: jsonb `||` overwrites key by key\n // and jsonb_strip_nulls then drops the keys the patch set to null (the\n // API's only way to unset a property). Atomic in the database, so two\n // concurrent identifies cannot lose each other's keys.\n const rows = await this.sql.query<{ props: unknown }>(\n `INSERT INTO ${this.t.profile} (user_id, props) VALUES ($1, jsonb_strip_nulls($2::text::jsonb))\n ON CONFLICT (user_id) DO UPDATE SET props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb)\n RETURNING props`,\n // EXCLUDED.props would be the stripped insert value, so the update merges the raw patch again\n [userId, JSON.stringify(props), JSON.stringify(props)]\n );\n return asJson<ProfileProps>(rows.at(0)?.props ?? {});\n }\n\n async listUserIds(): Promise<string[]> {\n const rows = await this.sql.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.profile} UNION SELECT DISTINCT user_id FROM ${this.t.event}`\n );\n return rows.map((row) => row.user_id);\n }\n\n listDevices(userId: string): Promise<PushDevice[]> {\n return this.sql.query<PushDevice>(\n `SELECT token, platform FROM ${this.t.device} WHERE user_id = $1 ORDER BY ts DESC`,\n [userId]\n );\n }\n\n async upsertDevice(device: DeviceInput): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.device} (token, user_id, platform, ts) VALUES ($1, $2, $3, ${TS('$4')})\n ON CONFLICT (token) DO UPDATE SET user_id = EXCLUDED.user_id, platform = EXCLUDED.platform, ts = EXCLUDED.ts`,\n [device.token, device.userId, device.platform, device.ts]\n );\n }\n\n async requestErasure(userId: string, at: number): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.erasure} (user_id, requested_at) VALUES ($1, ${TS('$2')})\n ON CONFLICT DO NOTHING`,\n [userId, at]\n );\n }\n\n async pendingErasures(): Promise<string[]> {\n const rows = await this.sql.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.erasure} ORDER BY requested_at`\n );\n return rows.map((row) => row.user_id);\n }\n\n async runningInstances(userId: string): Promise<string[]> {\n const rows = await this.sql.query<{ instance_id: string }>(\n `SELECT instance_id FROM ${this.t.entry} WHERE user_id = $1 AND status = 'running'`,\n [userId]\n );\n return rows.map((row) => row.instance_id);\n }\n\n async forgetUser(userId: string): Promise<void> {\n await this.sql.transaction(async (tx) => {\n // Children before the copies they belong to\n for (const child of [this.t.messageOpen, this.t.messageClick, this.t.messageLink]) {\n await tx.query(\n `DELETE FROM ${child} WHERE message_id IN (SELECT id FROM ${this.t.message} WHERE user_id = $1)`,\n [userId]\n );\n }\n for (const table of [\n this.t.message,\n this.t.instance,\n this.t.entry,\n this.t.subscription,\n this.t.segmentStart,\n this.t.segmentMember,\n this.t.suppression,\n this.t.device,\n this.t.event,\n this.t.profile,\n // The request goes with the data it asked to erase\n this.t.erasure,\n ]) {\n await tx.query(`DELETE FROM ${table} WHERE user_id = $1`, [userId]);\n }\n });\n }\n\n async isSuppressed(userId: string, channel: string): Promise<boolean> {\n const rows = await this.sql.query(\n `SELECT 1 AS x FROM ${this.t.suppression} WHERE user_id = $1 AND channel = $2`,\n [userId, channel]\n );\n return rows.length > 0;\n }\n\n async removeDevice(token: string): Promise<string | undefined> {\n const rows = await this.sql.query<{ user_id: string | null }>(\n `DELETE FROM ${this.t.device} WHERE token = $1 RETURNING user_id`,\n [token]\n );\n return rows[0]?.user_id ?? undefined;\n }\n\n async unbindDevice(token: string): Promise<string | undefined> {\n // The owner as it was before the update: RETURNING alone would only show the null\n const rows = await this.sql.query<{ user_id: string | null }>(\n `WITH old AS (SELECT token, user_id FROM ${this.t.device} WHERE token = $1 FOR UPDATE)\n UPDATE ${this.t.device} d SET user_id = NULL FROM old WHERE d.token = old.token\n RETURNING old.user_id`,\n [token]\n );\n return rows[0]?.user_id ?? undefined;\n }\n\n async listSegments(): Promise<{ id: string; condition: ConditionIR }[]> {\n const rows = await this.sql.query<{ id: string; condition: unknown }>(\n `SELECT id, condition FROM ${this.t.segment} ORDER BY id`\n );\n return rows.map((row) => ({\n id: row.id,\n condition: ConditionIR.parse(asJson(row.condition)),\n }));\n }\n\n async getSegmentCondition(id: string): Promise<ConditionIR | undefined> {\n const rows = await this.sql.query<{ condition: unknown }>(\n `SELECT condition FROM ${this.t.segment} WHERE id = $1`,\n [id]\n );\n const row = rows.at(0);\n return row === undefined ? undefined : ConditionIR.parse(asJson(row.condition));\n }\n\n async findTriggers(event: string): Promise<TriggerRoute[]> {\n const rows = await this.sql.query<{\n workflow: string;\n hash: string;\n where_clause: unknown;\n filter: unknown;\n }>(\n `SELECT workflow, hash, \"where\" AS where_clause, filter FROM ${this.t.trigger} WHERE event = $1`,\n [event]\n );\n return rows.map((row) => ({\n workflow: row.workflow,\n hash: row.hash,\n where: row.where_clause === null ? null : ConditionIR.parse(asJson(row.where_clause)),\n filter: row.filter === null ? null : ConditionIR.parse(asJson(row.filter)),\n }));\n }\n\n listSegmentTriggers(): Promise<SegmentTriggerRoute[]> {\n return this.sql.query<SegmentTriggerRoute>(\n `SELECT workflow, hash, segment FROM ${this.t.segmentTrigger}`\n );\n }\n\n async putWorkflowIR(workflow: WorkflowIR): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.ir} (content_hash, ir) VALUES ($1, $2::text::jsonb)\n ON CONFLICT (content_hash) DO UPDATE SET ir = EXCLUDED.ir`,\n [workflow.contentHash, JSON.stringify(workflow)]\n );\n }\n\n async getWorkflowIR(contentHash: string): Promise<WorkflowIR | undefined> {\n const rows = await this.sql.query<{ ir: unknown }>(\n `SELECT ir FROM ${this.t.ir} WHERE content_hash = $1`,\n [contentHash]\n );\n const row = rows.at(0);\n return row === undefined ? undefined : WorkflowIR.parse(asJson(row.ir));\n }\n\n replaceRoutes(bundle: BundleIR): Promise<void> {\n return this.sql.transaction((tx) => this.swapRoutes(tx, bundle));\n }\n\n async liveBundleVersion(): Promise<BundleVersion | undefined> {\n // In a transaction, so Hyperdrive answers from the database, not from its read cache\n const rows = await this.sql.transaction((tx) =>\n tx.query<{ version_id: string; created_at: number | string }>(\n `SELECT version_id, (extract(epoch FROM version_created_at) * 1000)::double precision AS created_at\n FROM ${this.t.deployment} WHERE scope = 'routes'`\n )\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { id: row.version_id, createdAt: Number(row.created_at) };\n }\n\n replaceRoutesIfNewer(bundle: BundleIR, version: BundleVersion): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n // The row lock decides between versions applying at once: the loser's WHERE re-reads the\n // winner's committed row and updates nothing\n const claimed = await tx.query(\n `INSERT INTO ${this.t.deployment} AS live (scope, version_id, version_created_at, applied_at)\n VALUES ('routes', $1, ${TS('$2')}, ${TS('$3')})\n ON CONFLICT (scope) DO UPDATE SET version_id = EXCLUDED.version_id,\n version_created_at = EXCLUDED.version_created_at, applied_at = EXCLUDED.applied_at\n WHERE live.version_created_at < EXCLUDED.version_created_at\n RETURNING scope`,\n [version.id, version.createdAt, Date.now()]\n );\n if (claimed.length === 0) return false;\n await this.swapRoutes(tx, bundle);\n return true;\n });\n }\n\n private async swapRoutes(tx: SqlClientLike, bundle: BundleIR): Promise<void> {\n await tx.query(`DELETE FROM ${this.t.trigger}`);\n await tx.query(`DELETE FROM ${this.t.segmentTrigger}`);\n await tx.query(`DELETE FROM ${this.t.segment}`);\n for (const segment of bundle.segments) {\n await tx.query(\n `INSERT INTO ${this.t.segment} (id, condition, hash) VALUES ($1, $2::text::jsonb, $3)`,\n [segment.id, JSON.stringify(segment.condition), segment.contentHash]\n );\n }\n for (const workflow of bundle.workflows) {\n if (workflow.trigger.type === 'event') {\n await tx.query(\n `INSERT INTO ${this.t.trigger} (workflow, hash, event, \"where\", filter) VALUES ($1, $2, $3, $4::text::jsonb, $5::text::jsonb)`,\n [\n workflow.id,\n workflow.contentHash,\n workflow.trigger.event,\n workflow.trigger.where === undefined ? null : JSON.stringify(workflow.trigger.where),\n workflow.trigger.filter === undefined ? null : JSON.stringify(workflow.trigger.filter),\n ]\n );\n } else if (workflow.trigger.type === 'segment') {\n await tx.query(\n `INSERT INTO ${this.t.segmentTrigger} (workflow, hash, segment) VALUES ($1, $2, $3)`,\n [workflow.id, workflow.contentHash, workflow.trigger.segment]\n );\n }\n }\n }\n\n async isSegmentMember(segment: string, userId: string): Promise<boolean> {\n const rows = await this.sql.query(\n `SELECT 1 AS x FROM ${this.t.segmentMember} WHERE segment = $1 AND user_id = $2`,\n [segment, userId]\n );\n return rows.length > 0;\n }\n\n async addSegmentMember(\n segment: string,\n userId: string,\n ts: number,\n workflows: readonly string[]\n ): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await tx.query(\n `INSERT INTO ${this.t.segmentMember} (segment, user_id, ts) VALUES ($1, $2, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [segment, userId, ts]\n );\n for (const workflow of workflows) {\n await tx.query(\n `INSERT INTO ${this.t.segmentStart} (segment, workflow, user_id, ts)\n VALUES ($1, $2, $3, ${TS('$4')}) ON CONFLICT DO NOTHING`,\n [segment, workflow, userId, ts]\n );\n }\n });\n }\n\n async pendingSegmentStarts(segment: string, userId: string): Promise<string[]> {\n const rows = await this.sql.query<{ workflow: string }>(\n `SELECT workflow FROM ${this.t.segmentStart} WHERE segment = $1 AND user_id = $2`,\n [segment, userId]\n );\n return rows.map((row) => row.workflow);\n }\n\n async completeSegmentStart(segment: string, userId: string, workflow: string): Promise<void> {\n await this.sql.query(\n `DELETE FROM ${this.t.segmentStart} WHERE segment = $1 AND user_id = $2 AND workflow = $3`,\n [segment, userId, workflow]\n );\n }\n\n async removeSegmentMember(segment: string, userId: string): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await tx.query(`DELETE FROM ${this.t.segmentStart} WHERE segment = $1 AND user_id = $2`, [\n segment,\n userId,\n ]);\n await tx.query(`DELETE FROM ${this.t.segmentMember} WHERE segment = $1 AND user_id = $2`, [\n segment,\n userId,\n ]);\n });\n }\n\n async enterJourney(entry: EntryInput): Promise<EntryOutcome> {\n // The (workflow, user_id) primary key makes the once policy atomic:\n // concurrent ingests race on the insert and exactly one wins.\n const inserted = await this.sql.query(\n `INSERT INTO ${this.t.entry} (workflow, user_id, instance_id, hash, status, ts)\n VALUES ($1, $2, $3, $4, 'running', ${TS('$5')})\n ON CONFLICT (workflow, user_id) DO NOTHING RETURNING instance_id`,\n [entry.workflow, entry.userId, entry.instanceId, entry.hash, entry.ts]\n );\n if (inserted.length > 0) return 'entered';\n\n // An existing row blocks the entry unless it records an instance that died:\n // 'failed' is reclaimed rather than barring the user forever over one\n // outage. The status test is inside the UPDATE, so of two concurrent\n // retries exactly one gets a row back.\n const claimed = await this.sql.query(\n `UPDATE ${this.t.entry} SET instance_id = $1, hash = $2, status = 'running', ts = ${TS('$3')}\n WHERE workflow = $4 AND user_id = $5 AND status = 'failed' RETURNING instance_id`,\n [entry.instanceId, entry.hash, entry.ts, entry.workflow, entry.userId]\n );\n return claimed.length > 0 ? 'reclaimed' : null;\n }\n\n async removeEntry(instanceId: string): Promise<void> {\n await this.sql.query(`DELETE FROM ${this.t.entry} WHERE instance_id = $1`, [instanceId]);\n }\n\n async setEntryStatus(instanceId: string, status: string): Promise<void> {\n await this.sql.query(`UPDATE ${this.t.entry} SET status = $1 WHERE instance_id = $2`, [\n status,\n instanceId,\n ]);\n }\n\n async findWakeHandles(userId: string, event: string): Promise<string[]> {\n const rows = await this.sql.query<{ handle: string }>(\n `SELECT DISTINCT wake_handle AS handle FROM ${this.t.subscription} WHERE user_id = $1 AND event = $2`,\n [userId, event]\n );\n return rows.map((row) => row.handle);\n }\n\n async subscribe(\n handle: string,\n userId: string,\n events: readonly string[],\n ts: number\n ): Promise<void> {\n for (const event of events) {\n await this.sql.query(\n `INSERT INTO ${this.t.subscription} (user_id, event, wake_handle, ts) VALUES ($1, $2, $3, ${TS('$4')}) ON CONFLICT DO NOTHING`,\n [userId, event, handle, ts]\n );\n }\n }\n\n async unsubscribe(handle: string): Promise<void> {\n await this.sql.query(`DELETE FROM ${this.t.subscription} WHERE wake_handle = $1`, [handle]);\n }\n}\n"],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { BundleIR, ConditionIR, WorkflowIR } from "../ir.cjs";
|
|
|
2
2
|
import { PushDevice } from "../engine/ports.cjs";
|
|
3
3
|
import { SqlClientLike, uuidv7 } from "./sql.cjs";
|
|
4
4
|
import { DEFAULT_SCHEMA } from "./tables.cjs";
|
|
5
|
-
import { DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, JourneyReporting, JourneyStore, ProfileProps, SegmentTriggerRoute, TriggerRoute } from "./index.cjs";
|
|
5
|
+
import { BundleVersion, DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, JourneyReporting, JourneyStore, ProfileProps, SegmentTriggerRoute, TriggerRoute } from "./index.cjs";
|
|
6
6
|
//#region src/store/postgres.d.ts
|
|
7
7
|
export interface PostgresJourneyStoreOptions {
|
|
8
8
|
/**
|
|
@@ -60,6 +60,9 @@ export declare class PostgresJourneyStore implements JourneyStore {
|
|
|
60
60
|
putWorkflowIR(workflow: WorkflowIR): Promise<void>;
|
|
61
61
|
getWorkflowIR(contentHash: string): Promise<WorkflowIR | undefined>;
|
|
62
62
|
replaceRoutes(bundle: BundleIR): Promise<void>;
|
|
63
|
+
liveBundleVersion(): Promise<BundleVersion | undefined>;
|
|
64
|
+
replaceRoutesIfNewer(bundle: BundleIR, version: BundleVersion): Promise<boolean>;
|
|
65
|
+
private swapRoutes;
|
|
63
66
|
isSegmentMember(segment: string, userId: string): Promise<boolean>;
|
|
64
67
|
addSegmentMember(segment: string, userId: string, ts: number, workflows: readonly string[]): Promise<void>;
|
|
65
68
|
pendingSegmentStarts(segment: string, userId: string): Promise<string[]>;
|