@shware/workflow 9.0.3 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -4
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +7 -7
- package/dist/cloudflare/bindings.d.mts +7 -7
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/dsl/flow.cjs +2 -1
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +2 -1
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/engine/interpreter.cjs +46 -10
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.mjs +46 -10
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +31 -6
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +31 -6
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/ir.cjs +2 -1
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +6 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +6 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -1
- package/dist/ir.mjs.map +1 -1
- package/dist/reporting/observer.cjs +2 -0
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.mjs +2 -0
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +8 -0
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +8 -0
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +9 -0
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +2 -0
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +2 -0
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +9 -0
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/tables.cjs +1 -0
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +1 -0
- package/dist/store/tables.mjs.map +1 -1
- package/dist/templates.cjs.map +1 -1
- package/dist/templates.d.cts +20 -0
- package/dist/templates.d.cts.map +1 -1
- package/dist/templates.d.mts +20 -0
- package/dist/templates.d.mts.map +1 -1
- package/dist/templates.mjs.map +1 -1
- package/dist/transports/cloudflare-email.cjs.map +1 -1
- package/dist/transports/cloudflare-email.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.cjs","names":["beginReadCommitted","lockUser","lockUserTransaction","uuidv7","UserErasedError"],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.cjs","names":["beginReadCommitted","lockUser","lockUserTransaction","uuidv7","UserErasedError"],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async claimMessage(idempotencyKey: string, at: number): Promise<boolean> {\n // One statement, so two runs of the same step cannot both claim: only the run that finds\n // claimed_at null writes it, and gets a row back. A write, so Hyperdrive runs it on the\n // database — no cache can answer \"unclaimed\" for a copy a crashed run claimed\n const rows = await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = ${TS('$2')}\n WHERE idempotency_key = $1 AND claimed_at IS NULL\n RETURNING 1 AS x`,\n [idempotencyKey, at]\n );\n return rows.length > 0;\n }\n\n async releaseMessage(idempotencyKey: string): Promise<void> {\n await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = NULL\n WHERE idempotency_key = $1 AND delivered_at IS NULL AND undelivered_reason IS NULL`,\n [idempotencyKey]\n );\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
|
@@ -60,6 +60,8 @@ export declare class PostgresReporting implements JourneyReporting {
|
|
|
60
60
|
}): Promise<void>;
|
|
61
61
|
recordMessage(message: MessageRecord): Promise<string>;
|
|
62
62
|
messageSettled(idempotencyKey: string): Promise<boolean>;
|
|
63
|
+
claimMessage(idempotencyKey: string, at: number): Promise<boolean>;
|
|
64
|
+
releaseMessage(idempotencyKey: string): Promise<void>;
|
|
63
65
|
settleMessage(idempotencyKey: string, result: {
|
|
64
66
|
delivered: true;
|
|
65
67
|
} | {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.d.cts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.d.cts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,aAAa,wBAAwB,aAAa;EAalD,eAAe,yBAAyB;EAQxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
@@ -60,6 +60,8 @@ export declare class PostgresReporting implements JourneyReporting {
|
|
|
60
60
|
}): Promise<void>;
|
|
61
61
|
recordMessage(message: MessageRecord): Promise<string>;
|
|
62
62
|
messageSettled(idempotencyKey: string): Promise<boolean>;
|
|
63
|
+
claimMessage(idempotencyKey: string, at: number): Promise<boolean>;
|
|
64
|
+
releaseMessage(idempotencyKey: string): Promise<void>;
|
|
63
65
|
settleMessage(idempotencyKey: string, result: {
|
|
64
66
|
delivered: true;
|
|
65
67
|
} | {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.d.mts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.d.mts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;qBAyCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAmCvC,eAAe,yBAAyB;EAaxC,aAAa,wBAAwB,aAAa;EAalD,eAAe,yBAAyB;EAQxC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,cAAc,oBAAoB,QAAQ;EAW1C,kBACJ,mBACA,YACA,0BACC;IAAU;IAAgB;;EAkCvB,iBAAiB,aAAa;IAAU;IAAiB"}
|
|
@@ -136,6 +136,15 @@ var PostgresReporting = class {
|
|
|
136
136
|
return (await this.sql.transaction((tx) => tx.query(`SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1
|
|
137
137
|
AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`, [idempotencyKey]))).length > 0;
|
|
138
138
|
}
|
|
139
|
+
async claimMessage(idempotencyKey, at) {
|
|
140
|
+
return (await this.sql.query(`UPDATE ${this.t.message} SET claimed_at = ${TS("$2")}
|
|
141
|
+
WHERE idempotency_key = $1 AND claimed_at IS NULL
|
|
142
|
+
RETURNING 1 AS x`, [idempotencyKey, at])).length > 0;
|
|
143
|
+
}
|
|
144
|
+
async releaseMessage(idempotencyKey) {
|
|
145
|
+
await this.sql.query(`UPDATE ${this.t.message} SET claimed_at = NULL
|
|
146
|
+
WHERE idempotency_key = $1 AND delivered_at IS NULL AND undelivered_reason IS NULL`, [idempotencyKey]);
|
|
147
|
+
}
|
|
139
148
|
async settleMessage(idempotencyKey, result, at) {
|
|
140
149
|
if (result.delivered) await this.sql.query(`UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS("$2")}) WHERE idempotency_key = $1`, [idempotencyKey, at]);
|
|
141
150
|
else await this.sql.query(`UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`, [idempotencyKey, result.reason]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-reporting.mjs","names":[],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"postgres-reporting.mjs","names":[],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type {\n ClickContext,\n Engagement,\n JourneyReporting,\n MessageRecord,\n MessageSummary,\n} from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, topic, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9::text, ${TS('$10')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.topic,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async messageSettled(idempotencyKey: string): Promise<boolean> {\n // In a transaction, so Hyperdrive answers from the database: a cached \"not settled\" would\n // send the copy again, which is the one thing this read exists to prevent\n const rows = await this.sql.transaction((tx) =>\n tx.query(\n `SELECT 1 AS x FROM ${this.t.message} WHERE idempotency_key = $1\n AND (delivered_at IS NOT NULL OR undelivered_reason IS NOT NULL)`,\n [idempotencyKey]\n )\n );\n return rows.length > 0;\n }\n\n async claimMessage(idempotencyKey: string, at: number): Promise<boolean> {\n // One statement, so two runs of the same step cannot both claim: only the run that finds\n // claimed_at null writes it, and gets a row back. A write, so Hyperdrive runs it on the\n // database — no cache can answer \"unclaimed\" for a copy a crashed run claimed\n const rows = await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = ${TS('$2')}\n WHERE idempotency_key = $1 AND claimed_at IS NULL\n RETURNING 1 AS x`,\n [idempotencyKey, at]\n );\n return rows.length > 0;\n }\n\n async releaseMessage(idempotencyKey: string): Promise<void> {\n await this.sql.query(\n `UPDATE ${this.t.message} SET claimed_at = NULL\n WHERE idempotency_key = $1 AND delivered_at IS NULL AND undelivered_reason IS NULL`,\n [idempotencyKey]\n );\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async lookupMessage(messageId: string): Promise<MessageSummary | undefined> {\n const rows = await this.sql.query<{ user_id: string; channel: string; topic: string | null }>(\n `SELECT user_id, channel, topic FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n return row === undefined\n ? undefined\n : { userId: row.user_id, channel: row.channel, topic: row.topic };\n }\n\n async recordUnsubscribe(\n messageId: string,\n at: number,\n scope: 'topic' | 'all' = 'topic'\n ): Promise<{ userId: string; topic: string | null } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string; topic: string | null }>(\n `SELECT user_id, topic FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n // A transactional copy carries no unsubscribe link: nothing to record, and the caller\n // answers as for a link that does not exist\n if (owner.topic === null) return { userId: owner.user_id, topic: null };\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string; topic: string | null }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id, topic`,\n [messageId, at]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n // The opt-out itself: the copy's topic, or every email (the channel-wide null-topic row)\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)\n VALUES ($1, 'email', $2::text, ${TS('$3')}) ON CONFLICT DO NOTHING`,\n [row.user_id, scope === 'all' ? null : row.topic, at]\n );\n return { userId: row.user_id, topic: row.topic };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
|
package/dist/store/tables.cjs
CHANGED
|
@@ -279,6 +279,7 @@ const TABLES = {
|
|
|
279
279
|
userId: text,
|
|
280
280
|
topic: nullable(text, "An email or push copy: its template's subscription topic (what an email's unsubscribe link opts out of); null for a transactional message, and for every other channel."),
|
|
281
281
|
sentAt: ts,
|
|
282
|
+
claimedAt: nullable(ts, "The copy was handed to the transport (written just before the send, cleared when the send threw). Claimed and never settled: the transport may have taken it — the engine does not send it again unless the template says `redelivery: 'resend'`."),
|
|
282
283
|
deliveredAt: nullable(ts, "The transport accepted it."),
|
|
283
284
|
deviceDeliveredAt: nullable(ts, "A push: the device reported it received (iOS's notification service extension, a browser's service worker)."),
|
|
284
285
|
undeliveredReason: nullable(text, "The transport settled it without delivering (a dead push token)."),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tables.cjs","names":[],"sources":["../../src/store/tables.ts"],"sourcesContent":["/**\n * The engine's Postgres tables, declared once. Everything that needs their shape derives it from\n * here: the drizzle schema file a host generates and migrates with its own tooling\n * (`renderDrizzleSchema`), the plain DDL (`renderPostgresDdl`), the store's table names, and the\n * check a deploy runs against the live database (`checkPostgresSchema`).\n *\n * The house rules hold for every table: no database defaults (the store writes every value,\n * timestamps included), timestamptz for time, and a UUID v7 row id — generated by the store and\n * guarded by a CHECK — wherever a table has a surrogate key. All tables live in one Postgres\n * schema of their own (`workflow` unless the config names another), so their short names never\n * meet the app's.\n */\n\nexport type ColumnType = 'uuid' | 'text' | 'jsonb' | 'timestamptz' | 'integer' | 'boolean';\n\nexport interface ColumnSpec {\n type: ColumnType;\n /** Columns are NOT NULL unless marked. */\n nullable?: boolean;\n /** One line for the generated file. */\n doc?: string;\n}\n\n/**\n * A unique constraint whose key may hold nulls, with `NULLS NOT DISTINCT` (Postgres 15+) so two\n * rows null in the same column still collide — the identity of a table whose key has an\n * optional part, where a primary key (NOT NULL throughout) cannot be.\n */\nexport interface UniqueConstraintSpec<K extends string = string> {\n columns: readonly K[];\n nullsNotDistinct: true;\n}\n\nexport interface TableSpec<K extends string = string> {\n /** Physical table name inside the schema. */\n name: string;\n doc: string;\n /** Keyed by the drizzle property name; the column name is its snake_case. */\n columns: Record<K, ColumnSpec>;\n /** Absent for a table whose identity is a `uniqueConstraints` entry (a nullable key part). */\n primaryKey?: readonly K[];\n /** A uuid column whose values must be version 7. */\n uuidV7?: K;\n uniqueIndexes?: readonly (readonly K[])[];\n /** Named `<table>_<columns>_unique`, as drizzle names an unnamed `unique()`. */\n uniqueConstraints?: readonly UniqueConstraintSpec<K>[];\n indexes?: readonly (readonly K[])[];\n /** Foreign keys, to another table's logical key. */\n references?: readonly {\n columns: readonly K[];\n table: string;\n foreignColumns: readonly string[];\n }[];\n}\n\nfunction table<K extends string>(spec: TableSpec<K>): TableSpec<K> {\n return spec;\n}\n\nconst uuid = { type: 'uuid' } as const;\nconst text = { type: 'text' } as const;\nconst jsonb = { type: 'jsonb' } as const;\nconst ts = { type: 'timestamptz' } as const;\nconst integer = { type: 'integer' } as const;\nconst boolean = { type: 'boolean' } as const;\nconst nullable = <C extends ColumnSpec>(column: C, doc?: string): ColumnSpec => ({\n ...column,\n nullable: true,\n ...(doc === undefined ? {} : { doc }),\n});\n\nexport const TABLES = {\n event: table({\n name: 'event',\n doc: \"Every ingested event, the fact base for `performed(...)` conditions. `event_id` is the occurrence's identity as the sender chose it (a retrying producer sends the same one again), scoped to the user: the (user_id, event_id) unique index makes a second delivery a no-op, and one sender's id can never block another user's event.\",\n columns: { id: uuid, eventId: text, userId: text, name: text, ts, payload: jsonb },\n primaryKey: ['id'],\n uuidV7: 'id',\n uniqueIndexes: [['userId', 'eventId']],\n indexes: [['userId', 'name', 'ts']],\n }),\n profile: table({\n name: 'profile',\n doc: 'Identified properties per user (`/ingest` merges into `props`; a null value means unset).',\n columns: {\n userId: text,\n props: jsonb,\n propsAt: nullable(\n jsonb,\n 'When each property was last written, epoch ms by key (a cleared one too): an older patch cannot overwrite it. Null for a row older than this column.'\n ),\n },\n primaryKey: ['userId'],\n }),\n device: table({\n name: 'device',\n doc: 'Push devices, one row per device, holding only who it belongs to now (`JourneyClient.registerDevice`): a signed-in `user_id`, or a `visitor_id` alone before sign-in. Signing out clears both rather than removing the row; a token APNs / FCM reports as gone is removed.',\n columns: {\n deviceId: nullable(\n text,\n 'The device across token rotations: a new token replaces the old one. Null when the producer sent none.'\n ),\n userId: nullable(text),\n visitorId: nullable(\n text,\n \"The visitor on the device now, kept once signed in: it tells one of a user's devices from another.\"\n ),\n token: text,\n platform: text,\n createdAt: ts,\n updatedAt: ts,\n },\n primaryKey: ['token'],\n uniqueIndexes: [['deviceId']],\n indexes: [['userId'], ['visitorId']],\n }),\n visitor: table({\n name: 'visitor',\n doc: \"Visitors the engine has heard from. `user_id` is who they became (`linkVisitor`): a late event of the visitor reaches the user, and the row outlives the user's erasure, so such an event finds the tombstone instead of starting a new visitor. One that never signs in is pruned once idle.\",\n columns: {\n visitorId: text,\n userId: nullable(text),\n createdAt: ts,\n updatedAt: ts,\n },\n primaryKey: ['visitorId'],\n indexes: [['userId'], ['updatedAt']],\n }),\n erasure: table({\n name: 'erasure',\n doc: \"Erasure tombstones (`forgetUser`): every write about a user is conditional on no row here. While `erased_at` is null the user's data stays until every running instance is stopped, and the scheduled sweep retries what is left; once set, the data is gone and the row stays so the user id is never written again.\",\n columns: {\n userId: text,\n requestedAt: ts,\n erasedAt: nullable(ts, 'When the data was erased; null while the erasure is under way.'),\n },\n primaryKey: ['userId'],\n }),\n suppression: table({\n name: 'suppression',\n doc: 'What a user opted out of: a topic on a channel (an email unsubscribe from one topic, a push topic switched off in the app), or the whole channel when `topic` is null — the engine sends nothing there, and `reachable(channel)` is false. One row per (user, channel, topic), nulls included.',\n columns: {\n userId: text,\n channel: text,\n topic: nullable(\n text,\n 'The subscription topic opted out of; null for the whole channel (every row from before topics existed).'\n ),\n ts,\n },\n uniqueConstraints: [{ columns: ['userId', 'channel', 'topic'], nullsNotDistinct: true }],\n }),\n segment: table({\n name: 'segment',\n doc: 'Deployed segment definitions, resolved by name at evaluation time.',\n columns: { id: text, condition: jsonb, hash: text },\n primaryKey: ['id'],\n }),\n trigger: table({\n name: 'trigger',\n doc: 'Event-trigger routes: `where` gates on the event payload, `filter` on the profile.',\n columns: {\n workflow: text,\n hash: text,\n event: text,\n where: nullable(jsonb),\n filter: nullable(jsonb),\n visitors: nullable(\n { type: 'boolean' },\n 'Whether a visitor not signed in may enter (`trigger.event(e.x, { visitors: true })`); signed-in users only otherwise.'\n ),\n version: nullable(\n text,\n 'The code version whose compiled bundle put this route here (null after a forced deploy): an instance it starts runs on that version, so its IR and its code always match.'\n ),\n },\n primaryKey: ['workflow'],\n indexes: [['event']],\n }),\n segmentTrigger: table({\n name: 'segment_trigger',\n doc: 'Segment-entry trigger routes.',\n columns: {\n workflow: text,\n hash: text,\n segment: text,\n version: nullable(\n text,\n 'The code version whose compiled bundle put this route here (null after a forced deploy): an instance it starts runs on that version, so its IR and its code always match.'\n ),\n },\n primaryKey: ['workflow'],\n indexes: [['segment']],\n }),\n segmentMember: table({\n name: 'segment_member',\n doc: 'Who is in each deployed segment and since when: kept by the scheduled sweep, and for trigger-routed segments on every event too.',\n columns: { segment: text, userId: text, ts },\n primaryKey: ['segment', 'userId'],\n }),\n segmentStart: table({\n name: 'segment_start',\n doc: 'Workflows a segment entry still has to start for a member: written with the membership, removed as each one starts, so a failed start is retried by the next evaluation instead of being hidden behind the membership.',\n columns: { segment: text, workflow: text, userId: text, ts },\n primaryKey: ['segment', 'workflow', 'userId'],\n }),\n segmentSnapshot: table({\n name: 'segment_snapshot',\n doc: \"Each segment's size at every sweep, the Studio's sparklines.\",\n columns: { segment: text, takenAt: ts, size: integer },\n primaryKey: ['segment', 'takenAt'],\n }),\n entry: table({\n name: 'entry',\n doc: 'Entry ledger: the (workflow, user) primary key is the once policy.',\n columns: {\n workflow: text,\n userId: text,\n instanceId: text,\n hash: text,\n status: text,\n ts,\n admittedAt: nullable(\n ts,\n 'When the runner admitted itself (`admitInstance`), before its first observable effect; null until then, so an erasure knows nothing of the instance has run yet.'\n ),\n claimedAt: nullable(\n ts,\n \"When this attempt claimed the entry (insert or reclaim), by the host's clock. `ts` is the triggering event's own time, which a producer may set far in the past, so only this tells how long an entry has waited for its instance; maintenance fails an entry left unadmitted with no instance behind it. Null on rows written before the column existed.\"\n ),\n },\n primaryKey: ['workflow', 'userId'],\n // admittedAt: maintenance looks up the few entries still unadmitted (`IS NULL` uses it)\n indexes: [['instanceId'], ['admittedAt']],\n }),\n subscription: table({\n name: 'subscription',\n doc: 'wait_until wake routing; the primary key makes repeated subscribes idempotent.',\n columns: { userId: text, event: text, wakeHandle: text, ts },\n primaryKey: ['wakeHandle', 'event'],\n indexes: [['userId', 'event']],\n }),\n execution: table({\n name: 'execution',\n doc: 'Durable executions whose start is an asynchronous submit (AWS Lambda durable functions): written with the entry, so a lost submit response is retried with the same name, target and payload instead of starting a second execution; and the wake callback the instance is parked on now. Unused on Cloudflare.',\n columns: {\n instanceId: text,\n workflow: text,\n userId: text,\n name: text,\n target: text,\n input: text,\n createdAt: ts,\n submittedAt: nullable(\n ts,\n 'The runtime accepted the start; null while it may still have to be retried.'\n ),\n arn: nullable(text),\n unresolvedAt: nullable(\n ts,\n 'Retries ran out before the runtime confirmed the start: whether it runs is unknown, and nothing retries it.'\n ),\n callbackId: nullable(text, 'The callback the current wait parks on; a wake completes it.'),\n generation: integer,\n deadlineAt: nullable(ts),\n checkedAt: nullable(\n ts,\n \"When maintenance last compared the runtime's status with the ledger.\"\n ),\n stoppedAt: nullable(\n ts,\n 'When it was stopped (an erasure): an invocation already running goes on to its next checkpoint, so the data waits until it has drained.'\n ),\n },\n primaryKey: ['instanceId'],\n indexes: [['workflow', 'userId'], ['submittedAt'], ['userId']],\n }),\n deployment: table({\n name: 'deployment',\n doc: \"The Worker version whose compiled bundle the routes came from, one row (`scope` = 'routes'): journeyWorker applies its own bundle only over an older version, so one still serving during a rollout cannot put its routes back. A forced `POST /bundle/deploy` records the version that served it; a hand-posted `POST /deploy` leaves it as it is.\",\n columns: { scope: text, versionId: text, versionCreatedAt: ts, appliedAt: ts },\n primaryKey: ['scope'],\n }),\n ir: table({\n name: 'ir',\n doc: 'Workflow bodies (WorkflowIR), content-addressed.',\n columns: { contentHash: text, ir: jsonb },\n primaryKey: ['contentHash'],\n }),\n instance: table({\n name: 'instance',\n doc: 'One row per journey instance, written when it enters: the node it sits on or last passed (none when a guard ended it before the first), and how it ended (`running` until then; `goal` is a conversion, at `finished_at`).',\n columns: {\n instanceId: text,\n workflow: text,\n userId: text,\n enteredAt: ts,\n nodeId: nullable(text),\n nodeAt: nullable(ts),\n status: text,\n finishedAt: nullable(ts),\n convertedAt: nullable(\n ts,\n 'When the goal was met, whatever the status: with `exitOnMatch: false` a converted instance ends `completed`.'\n ),\n },\n primaryKey: ['instanceId'],\n indexes: [\n ['workflow', 'status'],\n ['workflow', 'finishedAt'],\n ],\n }),\n message: table({\n name: 'message',\n doc: \"One row per message copy (a push fans out per device). The idempotency key is the engine's copy identity, so a retried step finds the row it wrote; `channel` is where the copy landed (`email`, `push_ios`, `push_android`).\",\n columns: {\n id: uuid,\n idempotencyKey: text,\n workflow: text,\n instanceId: text,\n nodeId: text,\n template: text,\n channel: text,\n userId: text,\n topic: nullable(\n text,\n \"An email or push copy: its template's subscription topic (what an email's unsubscribe link opts out of); null for a transactional message, and for every other channel.\"\n ),\n sentAt: ts,\n deliveredAt: nullable(ts, 'The transport accepted it.'),\n deviceDeliveredAt: nullable(\n ts,\n \"A push: the device reported it received (iOS's notification service extension, a browser's service worker).\"\n ),\n undeliveredReason: nullable(\n text,\n 'The transport settled it without delivering (a dead push token).'\n ),\n openedAt: nullable(ts, 'First open of any kind, by a person or a machine.'),\n humanOpenedAt: nullable(ts, 'First open a person made.'),\n clickedAt: nullable(ts),\n humanClickedAt: nullable(ts),\n unsubscribedAt: nullable(ts),\n },\n primaryKey: ['id'],\n uuidV7: 'id',\n uniqueIndexes: [['idempotencyKey']],\n indexes: [['workflow', 'sentAt'], ['instanceId']],\n }),\n messageLink: table({\n name: 'message_link',\n doc: \"A sent email's links in document order: the tracked URL names a message and a position, and the redirect goes only to the URL recorded here.\",\n columns: { messageId: uuid, position: integer, url: text },\n primaryKey: ['messageId', 'position'],\n references: [{ columns: ['messageId'], table: 'message', foreignColumns: ['id'] }],\n }),\n messageOpen: table({\n name: 'message_open',\n doc: \"Every open-pixel load or push tap, classified when recorded: `machine` when no person caused it, with the deciding rule in `reason`; the request's network (never its address) kept for revisiting the rules.\",\n columns: {\n id: uuid,\n messageId: uuid,\n openedAt: ts,\n machine: boolean,\n reason: nullable(text),\n userAgent: nullable(text),\n asn: nullable(integer),\n asOrganization: nullable(text),\n country: nullable(text),\n },\n primaryKey: ['id'],\n uuidV7: 'id',\n indexes: [['messageId']],\n references: [{ columns: ['messageId'], table: 'message', foreignColumns: ['id'] }],\n }),\n messageClick: table({\n name: 'message_click',\n doc: 'Every click on a tracked link, classified like opens.',\n columns: {\n id: uuid,\n messageId: uuid,\n position: integer,\n url: text,\n clickedAt: ts,\n machine: boolean,\n reason: nullable(text),\n userAgent: nullable(text),\n asn: nullable(integer),\n asOrganization: nullable(text),\n country: nullable(text),\n },\n primaryKey: ['id'],\n uuidV7: 'id',\n indexes: [['messageId']],\n references: [{ columns: ['messageId'], table: 'message', foreignColumns: ['id'] }],\n }),\n} satisfies Record<string, TableSpec>;\n\nexport type TableKey = keyof typeof TABLES;\n\n/** The engine's schema when the config names none. */\nexport const DEFAULT_SCHEMA = 'workflow';\n\n/** `eventId` → `event_id`. */\nexport function columnName(key: string): string {\n return key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);\n}\n\nconst quote = (name: string) => `\"${name.replace(/\"/g, '\"\"')}\"`;\n\n/** `<table>_<columns>_unique`: drizzle's name for an unnamed `unique()`, and the index behind it. */\nexport function uniqueConstraintName(spec: TableSpec, unique: UniqueConstraintSpec): string {\n return `${spec.name}_${unique.columns.map(columnName).join('_')}_unique`;\n}\n\n/** `<table>_<columns>_index`: the name drizzle-kit and `renderPostgresDdl` both give an unnamed index. */\nexport function indexName(spec: TableSpec, keys: readonly string[]): string {\n return `${spec.name}_${keys.map(columnName).join('_')}_index`;\n}\n\nconst SQL_TYPES: Record<ColumnType, string> = {\n uuid: 'uuid',\n text: 'text',\n jsonb: 'jsonb',\n timestamptz: 'timestamptz',\n integer: 'integer',\n boolean: 'boolean',\n};\n\n/**\n * Plain Postgres DDL for every table — for a host without a migration tool, and the tests. A\n * host that migrates with drizzle generates `renderDrizzleSchema` instead and lets drizzle-kit\n * write the migration.\n */\nexport function renderPostgresDdl(schema = DEFAULT_SCHEMA): string {\n const s = quote(schema);\n const out = [`CREATE SCHEMA IF NOT EXISTS ${s};`];\n for (const spec of Object.values(TABLES)) {\n const lines = Object.entries(spec.columns).map(\n ([key, column]) =>\n ` ${quote(columnName(key))} ${SQL_TYPES[column.type]}${column.nullable ? '' : ' NOT NULL'}`\n );\n if (spec.primaryKey !== undefined) {\n lines.push(` PRIMARY KEY (${spec.primaryKey.map((k) => quote(columnName(k))).join(', ')})`);\n }\n for (const unique of spec.uniqueConstraints ?? []) {\n const cols = unique.columns.map(columnName);\n lines.push(\n ` CONSTRAINT ${quote(uniqueConstraintName(spec, unique))} UNIQUE NULLS NOT DISTINCT (${cols.map(quote).join(', ')})`\n );\n }\n if (spec.uuidV7 !== undefined) {\n lines.push(\n ` CONSTRAINT ${quote(`${spec.name}_uuid_v7`)} CHECK (substring(${quote(columnName(spec.uuidV7))}::text, 15, 1) = '7')`\n );\n }\n for (const ref of spec.references ?? []) {\n const target = TABLES[ref.table as TableKey];\n lines.push(\n ` FOREIGN KEY (${ref.columns.map((k) => quote(columnName(k))).join(', ')}) REFERENCES ${s}.${quote(target.name)} (${ref.foreignColumns.map((k) => quote(columnName(k))).join(', ')})`\n );\n }\n out.push(`CREATE TABLE IF NOT EXISTS ${s}.${quote(spec.name)} (\\n${lines.join(',\\n')}\\n);`);\n const index = (unique: boolean) => (keys: readonly string[]) => {\n const cols = keys.map(columnName);\n out.push(\n `CREATE ${unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quote(indexName(spec, keys))} ON ${s}.${quote(spec.name)} (${cols.map(quote).join(', ')});`\n );\n };\n (spec.uniqueIndexes ?? []).forEach(index(true));\n (spec.indexes ?? []).forEach(index(false));\n }\n return `${out.join('\\n')}\\n`;\n}\n\nconst TIMESTAMP_OPTIONS = \"{ mode: 'string', withTimezone: true }\";\n\nconst DRIZZLE_TYPES: Record<ColumnType, (column: string) => string> = {\n uuid: (c) => `uuid('${c}')`,\n text: (c) => `text('${c}')`,\n jsonb: (c) => `jsonb('${c}')`,\n timestamptz: (c) => `timestamp('${c}', ${TIMESTAMP_OPTIONS})`,\n integer: (c) => `integer('${c}')`,\n boolean: (c) => `boolean('${c}')`,\n};\n\n/** The line width the generated file is laid out for: the formatters' common default. */\nconst PRINT_WIDTH = 100;\n\n/**\n * One `workflowSchema.table(...)` statement, laid out the way a prettier-compatible formatter\n * (prettier, oxfmt, biome) at `PRINT_WIDTH` lays it out, so that formatting the generated file\n * changes nothing and a regeneration shows only real changes: on one line when it fits; else\n * with the last argument \"hugged\" (the columns object, or the constraints arrow when the columns\n * fit on the first line) and its items one per line; else every argument on its own line, the\n * constraints array still collapsed when it fits. A column with a doc comment keeps the columns\n * object expanded, as a comment does for a formatter.\n */\n/** A column line past the width: a formatter breaks the timestamp options object, so this does too. */\nfunction wrapColumn(text: string, indent: string): string {\n if (`${indent}${text},`.length <= PRINT_WIDTH || !text.includes(TIMESTAMP_OPTIONS)) return text;\n return text.replace(\n TIMESTAMP_OPTIONS,\n `{\\n${indent} mode: 'string',\\n${indent} withTimezone: true,\\n${indent}}`\n );\n}\n\nfunction renderTable(\n name: string,\n table: string,\n columns: { doc: string | undefined; text: string }[],\n extras: string[]\n): string {\n const head = `export const ${name} = workflowSchema.table(`;\n const flatColumns = columns.some((c) => c.doc !== undefined)\n ? undefined\n : `{ ${columns.map((c) => c.text).join(', ')} }`;\n const expandedColumns = (indent: string) =>\n `{\\n${columns\n .map(\n (c) =>\n `${c.doc === undefined ? '' : `${indent} /** ${c.doc} */\\n`}${indent} ${wrapColumn(c.text, `${indent} `)},`\n )\n .join('\\n')}\\n${indent}}`;\n const flatExtras = `(table) => [${extras.join(', ')}]`;\n const expandedExtras = (indent: string) =>\n `(table) => [\\n${extras.map((e) => `${indent} ${e},`).join('\\n')}\\n${indent}]`;\n const fits = (line: string) => line.length <= PRINT_WIDTH;\n\n if (flatColumns !== undefined) {\n const oneLine = `${head}'${table}', ${flatColumns}${extras.length === 0 ? '' : `, ${flatExtras}`});`;\n if (fits(oneLine)) return oneLine;\n }\n if (extras.length === 0) return `${head}'${table}', ${expandedColumns('')});`;\n if (flatColumns !== undefined) {\n const first = `${head}'${table}', ${flatColumns}, (table) => [`;\n if (fits(first)) return `${first}\\n${extras.map((e) => ` ${e},`).join('\\n')}\\n]);`;\n }\n const third = fits(` ${flatExtras}`) ? flatExtras : expandedExtras(' ');\n return `${head}\\n '${table}',\\n ${expandedColumns(' ')},\\n ${third}\\n);`;\n}\n\nconst exportName = (key: string) => `workflow${key.charAt(0).toUpperCase()}${key.slice(1)}`;\n\n/**\n * The tables as one drizzle file: `export const workflowSchema = pgSchema(<schema>)` and every\n * table as `workflowSchema.table(...)`. The host re-exports it from its drizzle schema and\n * generates the migration with drizzle-kit, so the migration history stays the host's. The\n * output is deterministic — regenerate and diff it in CI to catch an SDK upgrade that changed the\n * tables.\n */\nexport function renderDrizzleSchema(schema = DEFAULT_SCHEMA): string {\n const specs = Object.entries(TABLES) as [TableKey, TableSpec][];\n const used = new Set<string>(['pgSchema']);\n const body: string[] = [];\n let needsSql = false;\n for (const [key, spec] of specs) {\n const columns = Object.entries(spec.columns).map(([prop, column]) => {\n used.add(column.type === 'timestamptz' ? 'timestamp' : column.type);\n const key =\n spec.primaryKey?.length === 1 && spec.primaryKey[0] === prop ? '.primaryKey()' : '';\n return {\n doc: column.doc,\n text: `${prop}: ${DRIZZLE_TYPES[column.type](columnName(prop))}${key}${column.nullable ? '' : '.notNull()'}`,\n };\n });\n const extras: string[] = [];\n if (spec.primaryKey !== undefined && spec.primaryKey.length > 1) {\n used.add('primaryKey');\n extras.push(\n `primaryKey({ columns: [${spec.primaryKey.map((k) => `table.${k}`).join(', ')}] })`\n );\n }\n for (const keys of spec.uniqueIndexes ?? []) {\n used.add('uniqueIndex');\n extras.push(`uniqueIndex().on(${keys.map((k) => `table.${k}`).join(', ')})`);\n }\n for (const unique of spec.uniqueConstraints ?? []) {\n used.add('unique');\n extras.push(\n `unique().on(${unique.columns.map((k) => `table.${k}`).join(', ')}).nullsNotDistinct()`\n );\n }\n for (const keys of spec.indexes ?? []) {\n used.add('index');\n extras.push(`index().on(${keys.map((k) => `table.${k}`).join(', ')})`);\n }\n for (const ref of spec.references ?? []) {\n used.add('foreignKey');\n extras.push(\n `foreignKey({ columns: [${ref.columns.map((k) => `table.${k}`).join(', ')}], foreignColumns: [${ref.foreignColumns.map((k) => `${exportName(ref.table)}.${k}`).join(', ')}] })`\n );\n }\n if (spec.uuidV7 !== undefined) {\n used.add('check');\n needsSql = true;\n extras.push(\n `check('${spec.name}_uuid_v7', sql\\`substring(\\${table.${spec.uuidV7}}::text, 15, 1) = '7'\\`)`\n );\n }\n body.push(`/** ${spec.doc} */\\n${renderTable(exportName(key), spec.name, columns, extras)}`);\n }\n const imports = [...used].sort();\n return [\n '// Generated by `workflow-ui generate` from @shware/workflow — do not edit; regenerate after',\n '// upgrading the SDK and let drizzle-kit write the migration.',\n ...(needsSql ? [\"import { sql } from 'drizzle-orm';\"] : []),\n `import {\\n${imports.map((name) => ` ${name},`).join('\\n')}\\n} from 'drizzle-orm/pg-core';`,\n '',\n `export const workflowSchema = pgSchema('${schema}');`,\n '',\n body.join('\\n\\n'),\n '',\n ].join('\\n');\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tables.cjs","names":[],"sources":["../../src/store/tables.ts"],"sourcesContent":["/**\n * The engine's Postgres tables, declared once. Everything that needs their shape derives it from\n * here: the drizzle schema file a host generates and migrates with its own tooling\n * (`renderDrizzleSchema`), the plain DDL (`renderPostgresDdl`), the store's table names, and the\n * check a deploy runs against the live database (`checkPostgresSchema`).\n *\n * The house rules hold for every table: no database defaults (the store writes every value,\n * timestamps included), timestamptz for time, and a UUID v7 row id — generated by the store and\n * guarded by a CHECK — wherever a table has a surrogate key. All tables live in one Postgres\n * schema of their own (`workflow` unless the config names another), so their short names never\n * meet the app's.\n */\n\nexport type ColumnType = 'uuid' | 'text' | 'jsonb' | 'timestamptz' | 'integer' | 'boolean';\n\nexport interface ColumnSpec {\n type: ColumnType;\n /** Columns are NOT NULL unless marked. */\n nullable?: boolean;\n /** One line for the generated file. */\n doc?: string;\n}\n\n/**\n * A unique constraint whose key may hold nulls, with `NULLS NOT DISTINCT` (Postgres 15+) so two\n * rows null in the same column still collide — the identity of a table whose key has an\n * optional part, where a primary key (NOT NULL throughout) cannot be.\n */\nexport interface UniqueConstraintSpec<K extends string = string> {\n columns: readonly K[];\n nullsNotDistinct: true;\n}\n\nexport interface TableSpec<K extends string = string> {\n /** Physical table name inside the schema. */\n name: string;\n doc: string;\n /** Keyed by the drizzle property name; the column name is its snake_case. */\n columns: Record<K, ColumnSpec>;\n /** Absent for a table whose identity is a `uniqueConstraints` entry (a nullable key part). */\n primaryKey?: readonly K[];\n /** A uuid column whose values must be version 7. */\n uuidV7?: K;\n uniqueIndexes?: readonly (readonly K[])[];\n /** Named `<table>_<columns>_unique`, as drizzle names an unnamed `unique()`. */\n uniqueConstraints?: readonly UniqueConstraintSpec<K>[];\n indexes?: readonly (readonly K[])[];\n /** Foreign keys, to another table's logical key. */\n references?: readonly {\n columns: readonly K[];\n table: string;\n foreignColumns: readonly string[];\n }[];\n}\n\nfunction table<K extends string>(spec: TableSpec<K>): TableSpec<K> {\n return spec;\n}\n\nconst uuid = { type: 'uuid' } as const;\nconst text = { type: 'text' } as const;\nconst jsonb = { type: 'jsonb' } as const;\nconst ts = { type: 'timestamptz' } as const;\nconst integer = { type: 'integer' } as const;\nconst boolean = { type: 'boolean' } as const;\nconst nullable = <C extends ColumnSpec>(column: C, doc?: string): ColumnSpec => ({\n ...column,\n nullable: true,\n ...(doc === undefined ? {} : { doc }),\n});\n\nexport const TABLES = {\n event: table({\n name: 'event',\n doc: \"Every ingested event, the fact base for `performed(...)` conditions. `event_id` is the occurrence's identity as the sender chose it (a retrying producer sends the same one again), scoped to the user: the (user_id, event_id) unique index makes a second delivery a no-op, and one sender's id can never block another user's event.\",\n columns: { id: uuid, eventId: text, userId: text, name: text, ts, payload: jsonb },\n primaryKey: ['id'],\n uuidV7: 'id',\n uniqueIndexes: [['userId', 'eventId']],\n indexes: [['userId', 'name', 'ts']],\n }),\n profile: table({\n name: 'profile',\n doc: 'Identified properties per user (`/ingest` merges into `props`; a null value means unset).',\n columns: {\n userId: text,\n props: jsonb,\n propsAt: nullable(\n jsonb,\n 'When each property was last written, epoch ms by key (a cleared one too): an older patch cannot overwrite it. Null for a row older than this column.'\n ),\n },\n primaryKey: ['userId'],\n }),\n device: table({\n name: 'device',\n doc: 'Push devices, one row per device, holding only who it belongs to now (`JourneyClient.registerDevice`): a signed-in `user_id`, or a `visitor_id` alone before sign-in. Signing out clears both rather than removing the row; a token APNs / FCM reports as gone is removed.',\n columns: {\n deviceId: nullable(\n text,\n 'The device across token rotations: a new token replaces the old one. Null when the producer sent none.'\n ),\n userId: nullable(text),\n visitorId: nullable(\n text,\n \"The visitor on the device now, kept once signed in: it tells one of a user's devices from another.\"\n ),\n token: text,\n platform: text,\n createdAt: ts,\n updatedAt: ts,\n },\n primaryKey: ['token'],\n uniqueIndexes: [['deviceId']],\n indexes: [['userId'], ['visitorId']],\n }),\n visitor: table({\n name: 'visitor',\n doc: \"Visitors the engine has heard from. `user_id` is who they became (`linkVisitor`): a late event of the visitor reaches the user, and the row outlives the user's erasure, so such an event finds the tombstone instead of starting a new visitor. One that never signs in is pruned once idle.\",\n columns: {\n visitorId: text,\n userId: nullable(text),\n createdAt: ts,\n updatedAt: ts,\n },\n primaryKey: ['visitorId'],\n indexes: [['userId'], ['updatedAt']],\n }),\n erasure: table({\n name: 'erasure',\n doc: \"Erasure tombstones (`forgetUser`): every write about a user is conditional on no row here. While `erased_at` is null the user's data stays until every running instance is stopped, and the scheduled sweep retries what is left; once set, the data is gone and the row stays so the user id is never written again.\",\n columns: {\n userId: text,\n requestedAt: ts,\n erasedAt: nullable(ts, 'When the data was erased; null while the erasure is under way.'),\n },\n primaryKey: ['userId'],\n }),\n suppression: table({\n name: 'suppression',\n doc: 'What a user opted out of: a topic on a channel (an email unsubscribe from one topic, a push topic switched off in the app), or the whole channel when `topic` is null — the engine sends nothing there, and `reachable(channel)` is false. One row per (user, channel, topic), nulls included.',\n columns: {\n userId: text,\n channel: text,\n topic: nullable(\n text,\n 'The subscription topic opted out of; null for the whole channel (every row from before topics existed).'\n ),\n ts,\n },\n uniqueConstraints: [{ columns: ['userId', 'channel', 'topic'], nullsNotDistinct: true }],\n }),\n segment: table({\n name: 'segment',\n doc: 'Deployed segment definitions, resolved by name at evaluation time.',\n columns: { id: text, condition: jsonb, hash: text },\n primaryKey: ['id'],\n }),\n trigger: table({\n name: 'trigger',\n doc: 'Event-trigger routes: `where` gates on the event payload, `filter` on the profile.',\n columns: {\n workflow: text,\n hash: text,\n event: text,\n where: nullable(jsonb),\n filter: nullable(jsonb),\n visitors: nullable(\n { type: 'boolean' },\n 'Whether a visitor not signed in may enter (`trigger.event(e.x, { visitors: true })`); signed-in users only otherwise.'\n ),\n version: nullable(\n text,\n 'The code version whose compiled bundle put this route here (null after a forced deploy): an instance it starts runs on that version, so its IR and its code always match.'\n ),\n },\n primaryKey: ['workflow'],\n indexes: [['event']],\n }),\n segmentTrigger: table({\n name: 'segment_trigger',\n doc: 'Segment-entry trigger routes.',\n columns: {\n workflow: text,\n hash: text,\n segment: text,\n version: nullable(\n text,\n 'The code version whose compiled bundle put this route here (null after a forced deploy): an instance it starts runs on that version, so its IR and its code always match.'\n ),\n },\n primaryKey: ['workflow'],\n indexes: [['segment']],\n }),\n segmentMember: table({\n name: 'segment_member',\n doc: 'Who is in each deployed segment and since when: kept by the scheduled sweep, and for trigger-routed segments on every event too.',\n columns: { segment: text, userId: text, ts },\n primaryKey: ['segment', 'userId'],\n }),\n segmentStart: table({\n name: 'segment_start',\n doc: 'Workflows a segment entry still has to start for a member: written with the membership, removed as each one starts, so a failed start is retried by the next evaluation instead of being hidden behind the membership.',\n columns: { segment: text, workflow: text, userId: text, ts },\n primaryKey: ['segment', 'workflow', 'userId'],\n }),\n segmentSnapshot: table({\n name: 'segment_snapshot',\n doc: \"Each segment's size at every sweep, the Studio's sparklines.\",\n columns: { segment: text, takenAt: ts, size: integer },\n primaryKey: ['segment', 'takenAt'],\n }),\n entry: table({\n name: 'entry',\n doc: 'Entry ledger: the (workflow, user) primary key is the once policy.',\n columns: {\n workflow: text,\n userId: text,\n instanceId: text,\n hash: text,\n status: text,\n ts,\n admittedAt: nullable(\n ts,\n 'When the runner admitted itself (`admitInstance`), before its first observable effect; null until then, so an erasure knows nothing of the instance has run yet.'\n ),\n claimedAt: nullable(\n ts,\n \"When this attempt claimed the entry (insert or reclaim), by the host's clock. `ts` is the triggering event's own time, which a producer may set far in the past, so only this tells how long an entry has waited for its instance; maintenance fails an entry left unadmitted with no instance behind it. Null on rows written before the column existed.\"\n ),\n },\n primaryKey: ['workflow', 'userId'],\n // admittedAt: maintenance looks up the few entries still unadmitted (`IS NULL` uses it)\n indexes: [['instanceId'], ['admittedAt']],\n }),\n subscription: table({\n name: 'subscription',\n doc: 'wait_until wake routing; the primary key makes repeated subscribes idempotent.',\n columns: { userId: text, event: text, wakeHandle: text, ts },\n primaryKey: ['wakeHandle', 'event'],\n indexes: [['userId', 'event']],\n }),\n execution: table({\n name: 'execution',\n doc: 'Durable executions whose start is an asynchronous submit (AWS Lambda durable functions): written with the entry, so a lost submit response is retried with the same name, target and payload instead of starting a second execution; and the wake callback the instance is parked on now. Unused on Cloudflare.',\n columns: {\n instanceId: text,\n workflow: text,\n userId: text,\n name: text,\n target: text,\n input: text,\n createdAt: ts,\n submittedAt: nullable(\n ts,\n 'The runtime accepted the start; null while it may still have to be retried.'\n ),\n arn: nullable(text),\n unresolvedAt: nullable(\n ts,\n 'Retries ran out before the runtime confirmed the start: whether it runs is unknown, and nothing retries it.'\n ),\n callbackId: nullable(text, 'The callback the current wait parks on; a wake completes it.'),\n generation: integer,\n deadlineAt: nullable(ts),\n checkedAt: nullable(\n ts,\n \"When maintenance last compared the runtime's status with the ledger.\"\n ),\n stoppedAt: nullable(\n ts,\n 'When it was stopped (an erasure): an invocation already running goes on to its next checkpoint, so the data waits until it has drained.'\n ),\n },\n primaryKey: ['instanceId'],\n indexes: [['workflow', 'userId'], ['submittedAt'], ['userId']],\n }),\n deployment: table({\n name: 'deployment',\n doc: \"The Worker version whose compiled bundle the routes came from, one row (`scope` = 'routes'): journeyWorker applies its own bundle only over an older version, so one still serving during a rollout cannot put its routes back. A forced `POST /bundle/deploy` records the version that served it; a hand-posted `POST /deploy` leaves it as it is.\",\n columns: { scope: text, versionId: text, versionCreatedAt: ts, appliedAt: ts },\n primaryKey: ['scope'],\n }),\n ir: table({\n name: 'ir',\n doc: 'Workflow bodies (WorkflowIR), content-addressed.',\n columns: { contentHash: text, ir: jsonb },\n primaryKey: ['contentHash'],\n }),\n instance: table({\n name: 'instance',\n doc: 'One row per journey instance, written when it enters: the node it sits on or last passed (none when a guard ended it before the first), and how it ended (`running` until then; `goal` is a conversion, at `finished_at`).',\n columns: {\n instanceId: text,\n workflow: text,\n userId: text,\n enteredAt: ts,\n nodeId: nullable(text),\n nodeAt: nullable(ts),\n status: text,\n finishedAt: nullable(ts),\n convertedAt: nullable(\n ts,\n 'When the goal was met, whatever the status: with `exitOnMatch: false` a converted instance ends `completed`.'\n ),\n },\n primaryKey: ['instanceId'],\n indexes: [\n ['workflow', 'status'],\n ['workflow', 'finishedAt'],\n ],\n }),\n message: table({\n name: 'message',\n doc: \"One row per message copy (a push fans out per device). The idempotency key is the engine's copy identity, so a retried step finds the row it wrote; `channel` is where the copy landed (`email`, `push_ios`, `push_android`).\",\n columns: {\n id: uuid,\n idempotencyKey: text,\n workflow: text,\n instanceId: text,\n nodeId: text,\n template: text,\n channel: text,\n userId: text,\n topic: nullable(\n text,\n \"An email or push copy: its template's subscription topic (what an email's unsubscribe link opts out of); null for a transactional message, and for every other channel.\"\n ),\n sentAt: ts,\n claimedAt: nullable(\n ts,\n \"The copy was handed to the transport (written just before the send, cleared when the send threw). Claimed and never settled: the transport may have taken it — the engine does not send it again unless the template says `redelivery: 'resend'`.\"\n ),\n deliveredAt: nullable(ts, 'The transport accepted it.'),\n deviceDeliveredAt: nullable(\n ts,\n \"A push: the device reported it received (iOS's notification service extension, a browser's service worker).\"\n ),\n undeliveredReason: nullable(\n text,\n 'The transport settled it without delivering (a dead push token).'\n ),\n openedAt: nullable(ts, 'First open of any kind, by a person or a machine.'),\n humanOpenedAt: nullable(ts, 'First open a person made.'),\n clickedAt: nullable(ts),\n humanClickedAt: nullable(ts),\n unsubscribedAt: nullable(ts),\n },\n primaryKey: ['id'],\n uuidV7: 'id',\n uniqueIndexes: [['idempotencyKey']],\n indexes: [['workflow', 'sentAt'], ['instanceId']],\n }),\n messageLink: table({\n name: 'message_link',\n doc: \"A sent email's links in document order: the tracked URL names a message and a position, and the redirect goes only to the URL recorded here.\",\n columns: { messageId: uuid, position: integer, url: text },\n primaryKey: ['messageId', 'position'],\n references: [{ columns: ['messageId'], table: 'message', foreignColumns: ['id'] }],\n }),\n messageOpen: table({\n name: 'message_open',\n doc: \"Every open-pixel load or push tap, classified when recorded: `machine` when no person caused it, with the deciding rule in `reason`; the request's network (never its address) kept for revisiting the rules.\",\n columns: {\n id: uuid,\n messageId: uuid,\n openedAt: ts,\n machine: boolean,\n reason: nullable(text),\n userAgent: nullable(text),\n asn: nullable(integer),\n asOrganization: nullable(text),\n country: nullable(text),\n },\n primaryKey: ['id'],\n uuidV7: 'id',\n indexes: [['messageId']],\n references: [{ columns: ['messageId'], table: 'message', foreignColumns: ['id'] }],\n }),\n messageClick: table({\n name: 'message_click',\n doc: 'Every click on a tracked link, classified like opens.',\n columns: {\n id: uuid,\n messageId: uuid,\n position: integer,\n url: text,\n clickedAt: ts,\n machine: boolean,\n reason: nullable(text),\n userAgent: nullable(text),\n asn: nullable(integer),\n asOrganization: nullable(text),\n country: nullable(text),\n },\n primaryKey: ['id'],\n uuidV7: 'id',\n indexes: [['messageId']],\n references: [{ columns: ['messageId'], table: 'message', foreignColumns: ['id'] }],\n }),\n} satisfies Record<string, TableSpec>;\n\nexport type TableKey = keyof typeof TABLES;\n\n/** The engine's schema when the config names none. */\nexport const DEFAULT_SCHEMA = 'workflow';\n\n/** `eventId` → `event_id`. */\nexport function columnName(key: string): string {\n return key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);\n}\n\nconst quote = (name: string) => `\"${name.replace(/\"/g, '\"\"')}\"`;\n\n/** `<table>_<columns>_unique`: drizzle's name for an unnamed `unique()`, and the index behind it. */\nexport function uniqueConstraintName(spec: TableSpec, unique: UniqueConstraintSpec): string {\n return `${spec.name}_${unique.columns.map(columnName).join('_')}_unique`;\n}\n\n/** `<table>_<columns>_index`: the name drizzle-kit and `renderPostgresDdl` both give an unnamed index. */\nexport function indexName(spec: TableSpec, keys: readonly string[]): string {\n return `${spec.name}_${keys.map(columnName).join('_')}_index`;\n}\n\nconst SQL_TYPES: Record<ColumnType, string> = {\n uuid: 'uuid',\n text: 'text',\n jsonb: 'jsonb',\n timestamptz: 'timestamptz',\n integer: 'integer',\n boolean: 'boolean',\n};\n\n/**\n * Plain Postgres DDL for every table — for a host without a migration tool, and the tests. A\n * host that migrates with drizzle generates `renderDrizzleSchema` instead and lets drizzle-kit\n * write the migration.\n */\nexport function renderPostgresDdl(schema = DEFAULT_SCHEMA): string {\n const s = quote(schema);\n const out = [`CREATE SCHEMA IF NOT EXISTS ${s};`];\n for (const spec of Object.values(TABLES)) {\n const lines = Object.entries(spec.columns).map(\n ([key, column]) =>\n ` ${quote(columnName(key))} ${SQL_TYPES[column.type]}${column.nullable ? '' : ' NOT NULL'}`\n );\n if (spec.primaryKey !== undefined) {\n lines.push(` PRIMARY KEY (${spec.primaryKey.map((k) => quote(columnName(k))).join(', ')})`);\n }\n for (const unique of spec.uniqueConstraints ?? []) {\n const cols = unique.columns.map(columnName);\n lines.push(\n ` CONSTRAINT ${quote(uniqueConstraintName(spec, unique))} UNIQUE NULLS NOT DISTINCT (${cols.map(quote).join(', ')})`\n );\n }\n if (spec.uuidV7 !== undefined) {\n lines.push(\n ` CONSTRAINT ${quote(`${spec.name}_uuid_v7`)} CHECK (substring(${quote(columnName(spec.uuidV7))}::text, 15, 1) = '7')`\n );\n }\n for (const ref of spec.references ?? []) {\n const target = TABLES[ref.table as TableKey];\n lines.push(\n ` FOREIGN KEY (${ref.columns.map((k) => quote(columnName(k))).join(', ')}) REFERENCES ${s}.${quote(target.name)} (${ref.foreignColumns.map((k) => quote(columnName(k))).join(', ')})`\n );\n }\n out.push(`CREATE TABLE IF NOT EXISTS ${s}.${quote(spec.name)} (\\n${lines.join(',\\n')}\\n);`);\n const index = (unique: boolean) => (keys: readonly string[]) => {\n const cols = keys.map(columnName);\n out.push(\n `CREATE ${unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quote(indexName(spec, keys))} ON ${s}.${quote(spec.name)} (${cols.map(quote).join(', ')});`\n );\n };\n (spec.uniqueIndexes ?? []).forEach(index(true));\n (spec.indexes ?? []).forEach(index(false));\n }\n return `${out.join('\\n')}\\n`;\n}\n\nconst TIMESTAMP_OPTIONS = \"{ mode: 'string', withTimezone: true }\";\n\nconst DRIZZLE_TYPES: Record<ColumnType, (column: string) => string> = {\n uuid: (c) => `uuid('${c}')`,\n text: (c) => `text('${c}')`,\n jsonb: (c) => `jsonb('${c}')`,\n timestamptz: (c) => `timestamp('${c}', ${TIMESTAMP_OPTIONS})`,\n integer: (c) => `integer('${c}')`,\n boolean: (c) => `boolean('${c}')`,\n};\n\n/** The line width the generated file is laid out for: the formatters' common default. */\nconst PRINT_WIDTH = 100;\n\n/**\n * One `workflowSchema.table(...)` statement, laid out the way a prettier-compatible formatter\n * (prettier, oxfmt, biome) at `PRINT_WIDTH` lays it out, so that formatting the generated file\n * changes nothing and a regeneration shows only real changes: on one line when it fits; else\n * with the last argument \"hugged\" (the columns object, or the constraints arrow when the columns\n * fit on the first line) and its items one per line; else every argument on its own line, the\n * constraints array still collapsed when it fits. A column with a doc comment keeps the columns\n * object expanded, as a comment does for a formatter.\n */\n/** A column line past the width: a formatter breaks the timestamp options object, so this does too. */\nfunction wrapColumn(text: string, indent: string): string {\n if (`${indent}${text},`.length <= PRINT_WIDTH || !text.includes(TIMESTAMP_OPTIONS)) return text;\n return text.replace(\n TIMESTAMP_OPTIONS,\n `{\\n${indent} mode: 'string',\\n${indent} withTimezone: true,\\n${indent}}`\n );\n}\n\nfunction renderTable(\n name: string,\n table: string,\n columns: { doc: string | undefined; text: string }[],\n extras: string[]\n): string {\n const head = `export const ${name} = workflowSchema.table(`;\n const flatColumns = columns.some((c) => c.doc !== undefined)\n ? undefined\n : `{ ${columns.map((c) => c.text).join(', ')} }`;\n const expandedColumns = (indent: string) =>\n `{\\n${columns\n .map(\n (c) =>\n `${c.doc === undefined ? '' : `${indent} /** ${c.doc} */\\n`}${indent} ${wrapColumn(c.text, `${indent} `)},`\n )\n .join('\\n')}\\n${indent}}`;\n const flatExtras = `(table) => [${extras.join(', ')}]`;\n const expandedExtras = (indent: string) =>\n `(table) => [\\n${extras.map((e) => `${indent} ${e},`).join('\\n')}\\n${indent}]`;\n const fits = (line: string) => line.length <= PRINT_WIDTH;\n\n if (flatColumns !== undefined) {\n const oneLine = `${head}'${table}', ${flatColumns}${extras.length === 0 ? '' : `, ${flatExtras}`});`;\n if (fits(oneLine)) return oneLine;\n }\n if (extras.length === 0) return `${head}'${table}', ${expandedColumns('')});`;\n if (flatColumns !== undefined) {\n const first = `${head}'${table}', ${flatColumns}, (table) => [`;\n if (fits(first)) return `${first}\\n${extras.map((e) => ` ${e},`).join('\\n')}\\n]);`;\n }\n const third = fits(` ${flatExtras}`) ? flatExtras : expandedExtras(' ');\n return `${head}\\n '${table}',\\n ${expandedColumns(' ')},\\n ${third}\\n);`;\n}\n\nconst exportName = (key: string) => `workflow${key.charAt(0).toUpperCase()}${key.slice(1)}`;\n\n/**\n * The tables as one drizzle file: `export const workflowSchema = pgSchema(<schema>)` and every\n * table as `workflowSchema.table(...)`. The host re-exports it from its drizzle schema and\n * generates the migration with drizzle-kit, so the migration history stays the host's. The\n * output is deterministic — regenerate and diff it in CI to catch an SDK upgrade that changed the\n * tables.\n */\nexport function renderDrizzleSchema(schema = DEFAULT_SCHEMA): string {\n const specs = Object.entries(TABLES) as [TableKey, TableSpec][];\n const used = new Set<string>(['pgSchema']);\n const body: string[] = [];\n let needsSql = false;\n for (const [key, spec] of specs) {\n const columns = Object.entries(spec.columns).map(([prop, column]) => {\n used.add(column.type === 'timestamptz' ? 'timestamp' : column.type);\n const key =\n spec.primaryKey?.length === 1 && spec.primaryKey[0] === prop ? '.primaryKey()' : '';\n return {\n doc: column.doc,\n text: `${prop}: ${DRIZZLE_TYPES[column.type](columnName(prop))}${key}${column.nullable ? '' : '.notNull()'}`,\n };\n });\n const extras: string[] = [];\n if (spec.primaryKey !== undefined && spec.primaryKey.length > 1) {\n used.add('primaryKey');\n extras.push(\n `primaryKey({ columns: [${spec.primaryKey.map((k) => `table.${k}`).join(', ')}] })`\n );\n }\n for (const keys of spec.uniqueIndexes ?? []) {\n used.add('uniqueIndex');\n extras.push(`uniqueIndex().on(${keys.map((k) => `table.${k}`).join(', ')})`);\n }\n for (const unique of spec.uniqueConstraints ?? []) {\n used.add('unique');\n extras.push(\n `unique().on(${unique.columns.map((k) => `table.${k}`).join(', ')}).nullsNotDistinct()`\n );\n }\n for (const keys of spec.indexes ?? []) {\n used.add('index');\n extras.push(`index().on(${keys.map((k) => `table.${k}`).join(', ')})`);\n }\n for (const ref of spec.references ?? []) {\n used.add('foreignKey');\n extras.push(\n `foreignKey({ columns: [${ref.columns.map((k) => `table.${k}`).join(', ')}], foreignColumns: [${ref.foreignColumns.map((k) => `${exportName(ref.table)}.${k}`).join(', ')}] })`\n );\n }\n if (spec.uuidV7 !== undefined) {\n used.add('check');\n needsSql = true;\n extras.push(\n `check('${spec.name}_uuid_v7', sql\\`substring(\\${table.${spec.uuidV7}}::text, 15, 1) = '7'\\`)`\n );\n }\n body.push(`/** ${spec.doc} */\\n${renderTable(exportName(key), spec.name, columns, extras)}`);\n }\n const imports = [...used].sort();\n return [\n '// Generated by `workflow-ui generate` from @shware/workflow — do not edit; regenerate after',\n '// upgrading the SDK and let drizzle-kit write the migration.',\n ...(needsSql ? [\"import { sql } from 'drizzle-orm';\"] : []),\n `import {\\n${imports.map((name) => ` ${name},`).join('\\n')}\\n} from 'drizzle-orm/pg-core';`,\n '',\n `export const workflowSchema = pgSchema('${schema}');`,\n '',\n body.join('\\n\\n'),\n '',\n ].join('\\n');\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tables.d.cts","names":[],"sources":["../../src/store/tables.ts"],"mappings":";;;;;;;;;;;;YAaY;iBAEK;EACf,MAAM;;EAEN;;EAEA;;;;;;;iBAQe,qBAAqB;EACpC,kBAAkB;EAClB;;iBAGe,UAAU;;EAEzB;EACA;;EAEA,SAAS,OAAO,GAAG;;EAEnB,sBAAsB;;EAEtB,SAAS;EACT,mCAAmC;;EAEnC,6BAA6B,qBAAqB;EAClD,6BAA6B;;EAE7B;IACE,kBAAkB;IAClB;IACA;;;qBAoBS;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"tables.d.cts","names":[],"sources":["../../src/store/tables.ts"],"mappings":";;;;;;;;;;;;YAaY;iBAEK;EACf,MAAM;;EAEN;;EAEA;;;;;;;iBAQe,qBAAqB;EACpC,kBAAkB;EAClB;;iBAGe,UAAU;;EAEzB;EACA;;EAEA,SAAS,OAAO,GAAG;;EAEnB,sBAAsB;;EAEtB,SAAS;EACT,mCAAmC;;EAEnC,6BAA6B,qBAAqB;EAClD,6BAA6B;;EAE7B;IACE,kBAAkB;IAClB;IACA;;;qBAoBS;;;;;;;;;;;;;;;;;;;;;;;;YA2UD,wBAAwB;;qBAGvB;;wBAGG,WAAW;;wBAOX,qBAAqB,MAAM,WAAW,QAAQ;;wBAK9C,UAAU,MAAM,WAAW;;;;;;wBAkB3B,kBAAkB;;;;;;;;wBAqHlB,oBAAoB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tables.d.mts","names":[],"sources":["../../src/store/tables.ts"],"mappings":";;;;;;;;;;;;YAaY;iBAEK;EACf,MAAM;;EAEN;;EAEA;;;;;;;iBAQe,qBAAqB;EACpC,kBAAkB;EAClB;;iBAGe,UAAU;;EAEzB;EACA;;EAEA,SAAS,OAAO,GAAG;;EAEnB,sBAAsB;;EAEtB,SAAS;EACT,mCAAmC;;EAEnC,6BAA6B,qBAAqB;EAClD,6BAA6B;;EAE7B;IACE,kBAAkB;IAClB;IACA;;;qBAoBS;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"tables.d.mts","names":[],"sources":["../../src/store/tables.ts"],"mappings":";;;;;;;;;;;;YAaY;iBAEK;EACf,MAAM;;EAEN;;EAEA;;;;;;;iBAQe,qBAAqB;EACpC,kBAAkB;EAClB;;iBAGe,UAAU;;EAEzB;EACA;;EAEA,SAAS,OAAO,GAAG;;EAEnB,sBAAsB;;EAEtB,SAAS;EACT,mCAAmC;;EAEnC,6BAA6B,qBAAqB;EAClD,6BAA6B;;EAE7B;IACE,kBAAkB;IAClB;IACA;;;qBAoBS;;;;;;;;;;;;;;;;;;;;;;;;YA2UD,wBAAwB;;qBAGvB;;wBAGG,WAAW;;wBAOX,qBAAqB,MAAM,WAAW,QAAQ;;wBAK9C,UAAU,MAAM,WAAW;;;;;;wBAkB3B,kBAAkB;;;;;;;;wBAqHlB,oBAAoB"}
|
package/dist/store/tables.mjs
CHANGED
|
@@ -278,6 +278,7 @@ const TABLES = {
|
|
|
278
278
|
userId: text,
|
|
279
279
|
topic: nullable(text, "An email or push copy: its template's subscription topic (what an email's unsubscribe link opts out of); null for a transactional message, and for every other channel."),
|
|
280
280
|
sentAt: ts,
|
|
281
|
+
claimedAt: nullable(ts, "The copy was handed to the transport (written just before the send, cleared when the send threw). Claimed and never settled: the transport may have taken it — the engine does not send it again unless the template says `redelivery: 'resend'`."),
|
|
281
282
|
deliveredAt: nullable(ts, "The transport accepted it."),
|
|
282
283
|
deviceDeliveredAt: nullable(ts, "A push: the device reported it received (iOS's notification service extension, a browser's service worker)."),
|
|
283
284
|
undeliveredReason: nullable(text, "The transport settled it without delivering (a dead push token)."),
|