/** * Top-level event categories. Each category has retention + routing * behavior. Downstream sinks can route per category. */ type EventCategory = 'intent' | 'interaction' | 'lifecycle' | 'audit' | 'experiment' | 'custom'; interface BaseEvent { /** Stable id assigned at emit time. */ id: string; /** Schema version this event was emitted against. */ schemaVersion: string; /** Category routes downstream sink behavior. */ category: EventCategory; /** Type discriminator within the category. */ kind: string; /** Unix epoch ms at emit time on the client. */ timestamp: number; /** Multi-tenant scope, omitted in single-tenant / extension modes. */ orgId?: string; visitorId?: string; sessionId?: string; /** Per-agent-run linkage, when applicable. */ runId?: string; /** Page context at emit time. */ page?: { path: string; locale?: string; title?: string; }; /** Browser / device snapshot — populated by the SDK once per session. */ device?: { ua?: string; deviceType?: 'desktop' | 'mobile' | 'tablet' | 'tv' | 'other'; locale?: string; }; /** Category-specific shape lives in `props`. */ props?: Record; } /** * Transport layer — batching, retry, ordering — for the unified ingest * stream. * * Pluggable sink: anything implementing `EventSink` works. The * transport handles the bits that every sink would otherwise reinvent: * * - Batching: events queue in memory, flush every N ms or M events * - Retry with exponential backoff, capped at 5 attempts * - Sequence ordering: events carry a monotonic seq per session * - Page-unload flush via Beacon API * - Optional gzip body when supported */ interface EventSink { send(batch: BaseEvent[]): Promise; /** Flush any sink-internal buffers. Optional. */ flush?(): Promise; /** Tear down. Optional. */ dispose?(): Promise; } /** * Local event store — persistent, queryable, capped. * * Hosts who install dddk usually want intent data SOMEWHERE: their * own backend, dddk-console, a BI warehouse. EventStore is the * "self-hosted by default" answer — the SDK keeps a rolling local * copy of every event in the end-user's browser (IndexedDB), capped * to a sensible default that the host can lift, query, and export to * CSV / NDJSON / SQL whenever they want. * * Storage is per-end-user (IndexedDB is origin-scoped) — not a * central DB. There is no per-store quota *we* enforce beyond the * browser's; the cap below is a politeness setting so the SDK * doesn't silently consume gigabytes of a visitor's quota. * * Cap policy: * - Default 50,000 events OR 30 days, whichever fires first. * - `onFull: 'ring'` (default) deletes the oldest events to make * room. The data is lost — by design, ring buffers don't keep * history. * - `onFull: 'drop-new'` rejects incoming events instead. Useful * when the host wants to keep early-session events (e.g. for * RL training) and tolerate gaps later. * - `onFull: { notifyHost }` hands the decision to the host. The * callback can drain to backend, clear locally, raise the cap, * or whatever — return value is ignored, just side-effects. * * Set `cap: { maxEvents: Infinity, maxDays: Infinity }` to disable * the cap entirely. */ interface Cap { /** Max events to retain. `Infinity` disables the count cap. */ maxEvents: number; /** Max retention in days. `Infinity` disables the age cap. */ maxDays: number; } interface CapInfo { /** Cap values currently in effect. */ cap: Cap; /** Current store size at the moment the cap triggered. */ current: { events: number; oldestTs: number | null; }; /** The event we were about to write when the cap hit. */ pendingEvent: BaseEvent; } /** * Host callback for the `notify-host` policy. The store does NOT auto- * evict before calling — the host gets to decide. After the callback * resolves, the store does NOT retry the failing write; if the host * wants the event kept, they must explicitly do so (e.g. by calling * `store.clear()` first to make room, then re-emit). This is the * "loud" mode — chosen by hosts that hate silent data loss. */ type NotifyHostHandler = (info: CapInfo) => void | Promise; type OnFullPolicy = 'ring' | 'drop-new' | { notifyHost: NotifyHostHandler; }; interface EventStoreOpts { /** IndexedDB database name. Default `'dddk-events'`. Hosts wiring * multiple isolated stores (e.g. one per tenant inside the same * browser) should pass distinct names. */ dbName?: string; /** Retention cap. Both bounds are optional and default to 50k / 30d. * `Infinity` on either disables that bound. */ cap?: Partial; /** Behavior when the cap is exceeded. Default `'ring'`. */ onFull?: OnFullPolicy; } interface EventQuery { /** Filter by category. Single value or array (OR). */ category?: EventCategory | EventCategory[]; /** Filter by kind. Single value or array (OR). */ kind?: string | string[]; /** Timestamp lower bound (inclusive, ms-epoch). */ from?: number; /** Timestamp upper bound (inclusive, ms-epoch). */ to?: number; /** Per-id filters. */ sessionId?: string; visitorId?: string; runId?: string; /** Pagination. */ limit?: number; offset?: number; /** Sort by timestamp. Default `'desc'` (newest first). */ order?: 'asc' | 'desc'; } /** * One opened EventStore wraps an IndexedDB connection. Hosts open it * once and reuse the instance for the page's lifetime. Closing is * optional — IndexedDB closes on tab unload automatically — but * `close()` exists for hot-reload / test cleanup. */ declare class EventStore { private db; private cap; private onFull; private writeInFlight; private constructor(); /** * Open (creating if needed) the underlying IndexedDB database. * Throws if IndexedDB is unavailable (SSR / Node / locked-down * iframes). Hosts evaluating dddk in those environments should * skip EventStore wiring. */ static open(opts?: EventStoreOpts): Promise; /** * Persist one event. Cap policy fires here. Resolves to: * `{ stored: true }` — written * `{ stored: false, reason: 'full' }` — cap rejected (drop-new) * `{ stored: false, reason: 'host-rejected' }` — host's notify callback ran, * store didn't auto-evict */ write(event: BaseEvent): Promise<{ stored: boolean; reason?: 'full' | 'host-rejected'; }>; private doWrite; private handleFull; /** * Query events. Filters that have a matching IDB index are applied * server-side; the rest run as in-memory filters on the retrieved * subset. For 50k-cap stores even a full scan is sub-50ms. */ query(q?: EventQuery): Promise; /** Total count of matching events. Filter shape mirrors `query`. */ count(q?: Pick): Promise; /** Aggregate stats. Cheap — uses the timestamp index. */ size(): Promise<{ events: number; oldestTs: number | null; newestTs: number | null; }>; /** Delete every event. Cap settings stay. */ clear(): Promise; /** * Delete a subset. Returns the count of deleted events. Useful for * "after I've shipped these to the backend, drop them locally". */ drop(opts: { olderThanMs?: number; keepLast?: number; matching?: Pick; }): Promise; /** * Return an `EventSink` view of this store so it can plug straight * into `Transport`: * * ```ts * const transport = new Transport({ * sinks: [new HttpSink(...), eventStore.sink()], * }); * ``` * * Errors at write time are swallowed (logged) — the transport's * other sinks shouldn't fail just because the local store hit a * quota or was rejected by `onFull: 'drop-new'`. */ sink(): EventSink; /** Close the IDB connection. Optional; called automatically on tab unload. */ close(): void; private tx; private put; private countAll; private oldestTimestamp; private newestTimestamp; private evictOlderThan; private evictOldestN; } /** * Canonical SQL schema for `dddk_events`. * * Every event the SDK emits (intent / interaction / lifecycle / audit / * experiment / custom — see `src/ingest/schema.ts`) maps to exactly one * row of this shape. Hosts who want to land events in their own SQL * store get a stable, versioned target to write a migration against. * Hosts who don't have a SQL store at all use the bundled * `EventStore` (IndexedDB) and reuse this row shape for export. * * Schema versioning rules mirror `EVENT_SCHEMA_VERSION` in * `src/ingest/schema.ts` — additive within a minor, bump on rename / * remove. The DDL strings here will track that. * * Three dialects ship out of the box: SQLite, Postgres, MySQL. Hosts * on other engines (BigQuery, Snowflake, ClickHouse, DuckDB, …) can * map the column list manually; the type annotations below give the * source of truth. * * Column choices: * - `id` is the canonical primary key. UUID-like string, 36 chars * in the default emit path, but the column allows any TEXT so * custom emitters can use shorter ids. * - `props_json` is a TEXT-encoded JSON blob. We don't shred props * into per-kind tables — that explodes schema-migration cost. For * queries Postgres / MySQL / SQLite all have native JSON access * (`->>`, `->`, `json_extract`); BigQuery / ClickHouse have their * own JSON types. Keeping `props_json` as TEXT is the least * surprising default that works everywhere. * - `timestamp` is INTEGER ms-since-epoch. Native datetime types * differ wildly per engine; ms-epoch round-trips cleanly into * every one of them. * * Indexes shipped: * - timestamp (range scans, ring-buffer eviction) * - (category, kind) (typed event queries) * - session_id (per-session funnels) * * Hosts running heavy BI workloads will want to add more indexes * (visitor_id, run_id, page_path). The DDL below is the minimum * sensible default — extend in your own migration. */ /** * Flat row shape — one row per event. Optional event fields land as * `null` so the SQL schema can declare them NULLable. `props_json` * is the JSON-stringified props object (always present, empty * object string `'{}'` if the event has no props). */ interface DddkEventRow { id: string; schema_version: string; category: string; kind: string; timestamp: number; org_id: string | null; visitor_id: string | null; session_id: string | null; run_id: string | null; page_path: string | null; page_locale: string | null; page_title: string | null; device_ua: string | null; device_type: string | null; device_locale: string | null; props_json: string; } /** Column list in the order the DDL declares — useful for INSERTs. */ declare const DDDK_EVENTS_COLUMNS: ReadonlyArray; /** SQLite DDL. Drop-in `sqlite3 mydb.sqlite < ddl.sql`. */ declare const DDDK_EVENTS_DDL_SQLITE = "CREATE TABLE IF NOT EXISTS dddk_events (\n id TEXT PRIMARY KEY,\n schema_version TEXT NOT NULL,\n category TEXT NOT NULL,\n kind TEXT NOT NULL,\n timestamp INTEGER NOT NULL,\n org_id TEXT,\n visitor_id TEXT,\n session_id TEXT,\n run_id TEXT,\n page_path TEXT,\n page_locale TEXT,\n page_title TEXT,\n device_ua TEXT,\n device_type TEXT,\n device_locale TEXT,\n props_json TEXT NOT NULL DEFAULT '{}'\n);\nCREATE INDEX IF NOT EXISTS idx_dddk_events_timestamp ON dddk_events(timestamp);\nCREATE INDEX IF NOT EXISTS idx_dddk_events_category_kind ON dddk_events(category, kind);\nCREATE INDEX IF NOT EXISTS idx_dddk_events_session ON dddk_events(session_id);\n"; /** Postgres DDL. `BIGINT` timestamp; `JSONB` if you'd rather move props * out of TEXT — the row mapper still emits stringified JSON either way. */ declare const DDDK_EVENTS_DDL_POSTGRES = "CREATE TABLE IF NOT EXISTS dddk_events (\n id TEXT PRIMARY KEY,\n schema_version TEXT NOT NULL,\n category TEXT NOT NULL,\n kind TEXT NOT NULL,\n timestamp BIGINT NOT NULL,\n org_id TEXT,\n visitor_id TEXT,\n session_id TEXT,\n run_id TEXT,\n page_path TEXT,\n page_locale TEXT,\n page_title TEXT,\n device_ua TEXT,\n device_type TEXT,\n device_locale TEXT,\n props_json TEXT NOT NULL DEFAULT '{}'\n);\nCREATE INDEX IF NOT EXISTS idx_dddk_events_timestamp ON dddk_events(timestamp);\nCREATE INDEX IF NOT EXISTS idx_dddk_events_category_kind ON dddk_events(category, kind);\nCREATE INDEX IF NOT EXISTS idx_dddk_events_session ON dddk_events(session_id);\n"; /** MySQL DDL. `BIGINT` timestamp; utf8mb4 collation to accept any prop. */ declare const DDDK_EVENTS_DDL_MYSQL = "CREATE TABLE IF NOT EXISTS dddk_events (\n id VARCHAR(64) PRIMARY KEY,\n schema_version VARCHAR(32) NOT NULL,\n category VARCHAR(32) NOT NULL,\n kind VARCHAR(64) NOT NULL,\n timestamp BIGINT NOT NULL,\n org_id VARCHAR(128),\n visitor_id VARCHAR(128),\n session_id VARCHAR(128),\n run_id VARCHAR(128),\n page_path VARCHAR(512),\n page_locale VARCHAR(32),\n page_title VARCHAR(512),\n device_ua VARCHAR(512),\n device_type VARCHAR(32),\n device_locale VARCHAR(32),\n props_json LONGTEXT NOT NULL,\n INDEX idx_dddk_events_timestamp (timestamp),\n INDEX idx_dddk_events_category_kind (category, kind),\n INDEX idx_dddk_events_session (session_id)\n) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n"; /** * Pick the DDL for a given dialect. Hosts on other engines should * read the column list + types above and write their own. */ type SqlDialect = 'sqlite' | 'postgres' | 'mysql'; declare function dddkEventsDDL(dialect: SqlDialect): string; /** Flatten an event into the canonical SQL row shape. Lossless. */ declare function eventToRow(event: BaseEvent): DddkEventRow; /** Reverse — useful when reading rows back from SQL to feed analytics * code that expects `BaseEvent`. Throws on malformed `props_json`. */ declare function rowToEvent(row: DddkEventRow): BaseEvent; /** * Schema mapper — transform a `BaseEvent` into whatever row shape the * host's destination wants. * * The default mapper produces the canonical `DddkEventRow` (see * `sql-schema.ts`). Hosts on a different DB schema swap in their * own `(event) => row` and the rest of the pipeline (`EventStore`, * `toCSV`, `toNDJSON`, `toSQL`) honors it. * * Function-based (not declarative JSON) on purpose: declarative * mappings always run out of expressiveness around things like * "derive `customer_tier` from `props.tier` when present, else fall * back to `props.tier_hint`". Hosts write a function; we don't * invent a mapping DSL. * * Returning `null` from a mapper FILTERS the event out — useful when * a host wants to drop a category entirely (e.g. only ship intent * events to BI, drop interaction events that are too noisy). */ /** * Map a `BaseEvent` to an arbitrary row shape. Return `null` to drop * the event. */ type SqlSchemaMapper> = (event: BaseEvent) => Row | null; /** * The bundled default — produces canonical `DddkEventRow`s. Use this * when your destination has run the `dddk_events` DDL unchanged. */ declare const defaultMapper: SqlSchemaMapper; /** * Build a mapper from a column → extractor map. Hosts who just want * to rename a few columns or compute a derived one don't need to * write a full function — they describe the row column by column. * * ```ts * const m = fieldMapper({ * event_id: (e) => e.id, * event_name: (e) => `${e.category}.${e.kind}`, * ts_ms: (e) => e.timestamp, * props: (e) => JSON.stringify(e.props ?? {}), * customer_tier: (e) => (e.props?.tier as string | undefined) ?? null, * }); * ``` * * Returning `null` from any extractor is fine — it lands as `null` in * the row. To DROP the whole event, wrap the mapper in a function * that returns `null` from the outer call. */ declare function fieldMapper>(map: { [K in keyof Row]: (event: BaseEvent) => Row[K]; }): SqlSchemaMapper; /** * Compose: filter THEN map. Returns a mapper that drops events for * which `predicate(event)` is false; events that pass get the inner * mapper applied. * * ```ts * const onlyIntent = filterEvents((e) => e.category === 'intent'); * const m = onlyIntent(defaultMapper); * ``` */ declare function filterEvents(predicate: (event: BaseEvent) => boolean): (inner: SqlSchemaMapper) => SqlSchemaMapper; /** * Exporters — turn events into a string the host can save, share, or * paste straight into a SQL client. * * Three formats out of the box: CSV, NDJSON, SQL. Each runs the event * through an optional `SqlSchemaMapper` first so hosts can reshape * rows to match their own destination schema. Default mapper produces * canonical `DddkEventRow` (see `sql-schema.ts`). * * Returns strings — not files. Hosts decide where they go: download * via `URL.createObjectURL`, copy to clipboard, paste into their own * upload pipeline, whatever. Keeping the exporters string-pure means * they work everywhere (browser / Node / Workers) and never touch * the filesystem. */ interface ToCSVOptions { /** Mapper. Default: canonical row. */ mapper?: SqlSchemaMapper; /** Restrict to these columns in the given order. Default: use the * union of all keys appearing in the mapped rows, sorted to a * stable order. */ columns?: string[]; /** Field delimiter. Default `,`. Use `\t` for TSV. */ delimiter?: string; /** Line ending. Default `\n`. */ lineEnding?: string; /** Emit the header row. Default `true`. */ header?: boolean; } /** * RFC-4180-ish CSV. Quotes any field that contains the delimiter, a * quote, a newline, or starts/ends with whitespace. Empty / null * fields render as empty (NOT the literal string "null"). */ declare function toCSV(events: BaseEvent[], opts?: ToCSVOptions): string; interface ToNDJSONOptions { /** Mapper. Default: canonical row. */ mapper?: SqlSchemaMapper; /** Line ending. Default `\n` — keep it for tools that strictly * parse NDJSON. */ lineEnding?: string; } /** * Newline-delimited JSON. One JSON object per line. Pipes cleanly * into `jq`, BigQuery `bq load`, ClickHouse `INSERT FROM INFILE`, * etc. */ declare function toNDJSON(events: BaseEvent[], opts?: ToNDJSONOptions): string; interface ToSQLOptions { /** Mapper. Default: canonical row. */ mapper?: SqlSchemaMapper; /** Destination table. Default `'dddk_events'`. */ table?: string; /** Dialect — drives identifier quoting + the prepended DDL. * Default `'sqlite'`. */ dialect?: SqlDialect; /** Restrict to these columns. Default: union of mapped row keys. */ columns?: string[]; /** Prepend `CREATE TABLE IF NOT EXISTS …`. Default `false` — * most hosts run the migration once, not per-export. */ includeDDL?: boolean; /** Batch inserts into multi-row VALUES groups of this size. * Default `100`. Drop to `1` for one INSERT per event when * debugging. Many SQL engines cap parsed statement size; 100 * keeps each statement well under typical limits. */ batchSize?: number; } /** * Produce a runnable SQL script. Identifier + value quoting follows * each dialect's convention. Hosts should pipe the output straight * into their SQL client OR wrap in a transaction at the call site. */ declare function toSQL(events: BaseEvent[], opts?: ToSQLOptions): string; export { type Cap, type CapInfo, DDDK_EVENTS_COLUMNS, DDDK_EVENTS_DDL_MYSQL, DDDK_EVENTS_DDL_POSTGRES, DDDK_EVENTS_DDL_SQLITE, type DddkEventRow, type EventQuery, EventStore, type EventStoreOpts, type NotifyHostHandler, type OnFullPolicy, type SqlDialect, type SqlSchemaMapper, type ToCSVOptions, type ToNDJSONOptions, type ToSQLOptions, dddkEventsDDL, defaultMapper, eventToRow, fieldMapper, filterEvents, rowToEvent, toCSV, toNDJSON, toSQL };