import { SmrtCollection } from '@happyvertical/smrt-core'; import { DatabaseInterface } from '@happyvertical/sql'; import { ReferralLink } from '../models/ReferralLink.js'; import { ReferralTouch } from '../models/ReferralTouch.js'; /** Length of generated share codes. */ export declare const REFERRAL_CODE_LENGTH = 10; /** Alphabet generated codes draw from (lowercase alphanumeric). */ export declare const REFERRAL_CODE_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; /** Attempts before {@link ReferralLinkCollection.createWithUniqueCode} gives up. */ export declare const MAX_CODE_GENERATION_ATTEMPTS = 5; /** Default UTF-8 byte ceiling for the exact persisted click evidence JSON. */ export declare const DEFAULT_REFERRAL_CLICK_EVIDENCE_MAX_BYTES = 4096; /** Maximum UTF-8 size of a caller-supplied replay key. */ export declare const MAX_REFERRAL_CLICK_IDEMPOTENCY_KEY_BYTES = 256; /** * Generate a crypto-random share code: {@link REFERRAL_CODE_LENGTH} * characters drawn uniformly from {@link REFERRAL_CODE_ALPHABET} via * rejection-sampled `crypto.randomBytes` (no modulo bias). */ export declare function generateReferralCode(): string; /** Input for {@link ReferralLinkCollection.createWithUniqueCode}. */ export interface CreateReferralLinkInput { referrerId: string; programId: string; targetUrl?: string; label?: string; tenantId?: string | null; /** * Test seam / customization point: overrides the random code generator. * Must return {@link REFERRAL_CODE_LENGTH} lowercase-alphanumeric * characters; collisions with existing codes are retried up to * {@link MAX_CODE_GENERATION_ATTEMPTS} times. */ generateCode?: () => string; } /** Why {@link ReferralLinkCollection.recordClick} refused to record. */ export type RecordClickRefusal = 'unknown_code' | 'link_disabled'; /** * Result of {@link ReferralLinkCollection.recordClick}. On success `link` * and `touch` are both set and `refused` is absent. On refusal `touch` is * `null`, `refused` names the reason, and `link` carries the (disabled) link * for `'link_disabled'` / `null` for `'unknown_code'`. * * When the click self-transacted (pool-bound collection, no * {@link RecordClickInput.transaction}), models are re-read on the * collection's database after commit. When the click participated in a * caller transaction, models are bound to that transaction — current while * it is open; carry ids across the commit boundary for long-lived use. */ export interface RecordClickResult { link: ReferralLink | null; touch: ReferralTouch | null; refused?: RecordClickRefusal; /** Stable key used for a successful click (generated when omitted). */ idempotencyKey?: string; /** `true` when this call returned the already-committed touch. */ replayed?: boolean; } /** Input for {@link ReferralLinkCollection.recordClick}. */ export interface RecordClickInput { code: string; /** * Stable caller replay key. Retries must reuse the exact value. When * omitted, Sales generates a unique one-shot UUID for backward * compatibility and returns it in the successful result. Supplied keys * must be well-formed Unicode and at most 256 UTF-8 bytes. */ idempotencyKey?: string; /** * Extra evidence merged into the touch JSON (UA, IP hash, …). Sales owns * and overwrites the reserved `code`, `linkId`, and `targetUrl` keys. */ evidence?: Record; /** * Creation-time UTF-8 byte ceiling for the FINAL persisted JSON envelope, * including Sales-owned `code`, `linkId`, and `targetUrl`. Defaults to * {@link DEFAULT_REFERRAL_CLICK_EVIDENCE_MAX_BYTES}; an exact committed * replay does not reapply a later invocation's ceiling. */ maxEvidenceBytes?: number; /** When the click occurred; defaults to now. */ occurredAt?: Date; /** * Prospect identity when the edge already knows it (e.g. a logged-in * visitor or a pre-created lead). Without a subject pair the touch is * anonymous and `AttributionService.resolve` cannot gather it by subject — * the intake handler must then append a second, identified touch once the * prospect materializes (same `occurredAt` preserves credit ordering). */ subjectKind?: string; subjectId?: string; /** * Caller-owned open transaction this click should participate in: the * database view your `db.transaction(async (tx) => …)` callback received, * or a still-active `beginTransaction()` handle. When provided, every * read and write of this click runs on it and Sales opens NO transaction * of its own — atomicity, rollback, and locking belong to the caller's * transaction, and the click sees the caller's uncommitted rows (a link * created earlier in the same transaction resolves normally). * * Passing a pool-level database here is refused with a typed * `'invalid_transaction'` {@link ReferralClickValidationError} — running * the click's writes outside a transaction would abandon the atomicity * guarantee, silently. * * Result models are bound to this transaction: they are current while it * is open, but hold its connection afterwards — carry ids across the * commit boundary and re-fetch on a pool-bound collection for long-lived * use. */ transaction?: DatabaseInterface; } export type ReferralClickReplayMismatchField = 'idempotencyKey' | 'tenantId' | 'code' | 'linkId' | 'targetUrl' | 'referrerId' | 'programId' | 'subjectKind' | 'subjectId' | 'occurredAt' | 'evidence' | 'intent'; /** Typed fail-closed result for a replay key reused with another click. */ export declare class ReferralClickReplayConflictError extends Error { readonly idempotencyKey: string; readonly mismatches: readonly ReferralClickReplayMismatchField[]; readonly code: "REFERRAL_CLICK_REPLAY_CONFLICT"; constructor(idempotencyKey: string, mismatches: readonly ReferralClickReplayMismatchField[]); } export type ReferralClickValidationReason = 'invalid_idempotency_key' | 'invalid_occurred_at' | 'invalid_evidence' | 'invalid_max_evidence_bytes' | 'evidence_too_large' | 'transaction_unavailable' | 'invalid_transaction' | 'operation_link_missing' | 'operation_touch_missing' | 'link_update_conflict'; /** Actionable validation/integrity error raised without a partial click. */ export declare class ReferralClickValidationError extends Error { readonly reason: ReferralClickValidationReason; readonly details?: Readonly> | undefined; readonly code: "REFERRAL_CLICK_VALIDATION_ERROR"; constructor(reason: ReferralClickValidationReason, message: string, details?: Readonly> | undefined); } export declare class ReferralLinkCollection extends SmrtCollection { static readonly _itemClass: typeof ReferralLink; /** All links for a referrer, newest first. */ findByReferrer(referrerId: string): Promise; /** * Look up a link by its globally unique code (codes are minted lowercase; * the lookup normalizes case so shared codes survive re-typing). */ findByCode(code: string): Promise; /** * Create a link with a freshly minted, uniqueness-checked code. * * Codes come from `crypto.randomBytes` mapped to * {@link REFERRAL_CODE_LENGTH} lowercase-alphanumeric characters. The * check-then-insert loop retries on collision up to * {@link MAX_CODE_GENERATION_ATTEMPTS} times, then throws — at 36^10 * possible codes repeated collisions mean the generator is broken (or a * test seam returns a constant), not bad luck. */ createWithUniqueCode(input: CreateReferralLinkInput): Promise; /** * Record a click on a share code. A private operation fence, immutable * ReferralTouch, and {@link ReferralLink.clickCount} increment commit in * one supported database transaction. An exact `idempotencyKey` replay * returns the original touch without another increment; changed immutable * intent raises {@link ReferralClickReplayConflictError}. * * Transactions: by default (collection bound to a pool-level database) * the click opens and commits its own transaction, and the returned * models are re-read on the collection's database after commit. To record * a click inside YOUR transaction — required whenever your transaction * holds locks the click needs (above all the `referral_links` row it * increments) or created the link it resolves — either pass the * transaction database as {@link RecordClickInput.transaction} or call * `recordClick` on a collection bound to it * (`ReferralLinkCollection.create({ db: tx, _reuseInitializedDb: true, * _deferRuntimeInitialization: true })`). Both participate in the caller * transaction instead of nesting (a nested adapter `transaction()` takes * an independent pooled connection: it deadlocks undetectably on locks * your transaction holds and cannot see your uncommitted rows — * happyvertical/sdk#1108) and return models bound to it: commit/rollback * and durability belong to you, and refusals/replays reflect your * transaction's view. * * Evidence is canonicalized, Sales-owned `code`/`linkId`/`targetUrl` fields * are applied, and the UTF-8 bytes of that exact persisted JSON are checked * before any write. The default bound is * {@link DEFAULT_REFERRAL_CLICK_EVIDENCE_MAX_BYTES}. * * Refusals return a typed result instead of throwing (clicks arrive from * the edge — an unknown or disabled code is an expected outcome, not an * exception): `refused: 'unknown_code'` (no such code; `link: null`) or * `refused: 'link_disabled'` (link exists but is disabled; the link is * returned, nothing is written). */ recordClick(input: RecordClickInput): Promise; /** * Bind the click's working collections to one transaction database. The * transaction is the same initialized database on a pinned connection, so * the lightweight-binding flags are requested (mirrors * `CommissionPayoutService.txOptions`). Note `SmrtCollection.create()` * currently forwards only whitelisted options — the two internal flags * are dropped there today, and it is the same-URL system-table cache that * keeps bootstrap DDL out of the transaction in practice; the flags make * the intent explicit and take effect when core forwards them. */ private static createClickCollections; /** Rebind public result models to the caller's database after commit. */ private rehydrateRecordClickResult; private recordClickInTransaction; private replayClick; } export default ReferralLinkCollection; //# sourceMappingURL=ReferralLinkCollection.d.ts.map