diff --git a/content/develop/ai/context-engine/data-integration/_index.md b/content/develop/ai/context-engine/data-integration/_index.md index fa86507ca9..acffe30371 100644 --- a/content/develop/ai/context-engine/data-integration/_index.md +++ b/content/develop/ai/context-engine/data-integration/_index.md @@ -60,6 +60,8 @@ RDI pipelines are defined through configuration. You specify which source databa See the [RDI quick start](/content/operate/rc/rdi/quick-start.md) for a step-by-step walkthrough syncing a live PostgreSQL source to Redis Cloud. +To watch a pipeline sync a PostgreSQL database without setting one up, try the [interactive demo](/content/develop/ai/context-engine/data-integration/interactive-demo.md). + ## Redis Data Integration overview AI agents are only as reliable as the data they work with. RDI solves the freshness problem by using [Change Data Capture (CDC)](https://en.wikipedia.org/wiki/Change_data_capture) to detect changes in your source database and propagate them to Redis within seconds. Agents interact only with Redis, which is fast, predictable, and always current. diff --git a/content/develop/ai/context-engine/data-integration/interactive-demo.md b/content/develop/ai/context-engine/data-integration/interactive-demo.md new file mode 100644 index 0000000000..2a86d38fb2 --- /dev/null +++ b/content/develop/ai/context-engine/data-integration/interactive-demo.md @@ -0,0 +1,30 @@ +--- +Title: Redis Data Integration interactive demo +alwaysopen: false +categories: +- docs +- develop +- ai +description: Deploy an RDI pipeline for a PostgreSQL database, follow inserts, updates, and deletes into Redis, and change a table's keys with a job file. +linkTitle: Interactive demo +hideListLinks: true +weight: 10 +--- + +Redis Data Integration (RDI) keeps Redis in sync with a relational database, so your app and its agents read from Redis instead of the source. This demo follows a food delivery app that keeps its data in PostgreSQL. You deploy the pipeline, run SQL statements against the source, and see each change reach Redis. + +The demo runs in your browser and doesn't connect to an RDI pipeline or a database. The configuration, job file, key names, and `redis-di status` output follow the [RDI documentation](/content/integrate/redis-data-integration/_index.md). The data and timings are illustrative. The customers, restaurants, and orders are the same sample data as the [Context Retriever interactive demo](/content/develop/ai/context-engine/context-retriever/interactive-demo.md). + +{{< rdi-demo >}} + +## What the demo shows + +1. **Initial sync.** When you deploy a pipeline, RDI takes a snapshot of the tables you select and writes each row to Redis under its own key, as a hash or a JSON document. +2. **Change data capture.** After the snapshot, RDI captures every insert, update, and delete committed to those tables and applies it to Redis, usually within a few seconds. +3. **Job files.** A job file changes how RDI writes one table, such as its key pattern or its fields. A job only affects changes captured after you deploy it, until you reset the pipeline, and a reset doesn't delete the keys RDI wrote before. + +## Next steps + +- Follow the [RDI quick start](/content/operate/rc/rdi/quick-start.md) to sync a PostgreSQL database to Redis Cloud. +- Learn how to [define a data pipeline](/content/operate/rc/rdi/define.md) on Redis Cloud. +- See the [job file examples](/content/integrate/redis-data-integration/data-pipelines/transform-examples/_index.md) for more transformations. diff --git a/layouts/shortcodes/rdi-demo.html b/layouts/shortcodes/rdi-demo.html new file mode 100644 index 0000000000..5e84b197a0 --- /dev/null +++ b/layouts/shortcodes/rdi-demo.html @@ -0,0 +1,32 @@ +{{- /* + rdi-demo + -------- + Scripted, dependency-free demo of Redis Data Integration (RDI): a + pipeline syncs three PostgreSQL tables into Redis, SQL statements the + reader runs flow through change data capture, and a job file changes one + table's keys. Runs entirely in the browser; no pipeline is called. + + Usage (no arguments): + {{< rdi-demo >}} + + Base styles come from context-retriever-demo.css so the Iris demos share + one look; rdi-demo.css adds this demo's pieces. + + The assets are emitted on every call rather than deduplicated with + .Page.Store: the store survives `hugo server` rebuilds, so a page that + isn't edited would lose its assets after the first rebuild. The script + guards against running twice if the shortcode is used more than once. +*/ -}} + +{{- /* ?v= is a hash of the file, so a reload never runs a stale cached copy. */ -}} +{{- $v := dict -}} +{{- range slice "css/context-retriever-demo.css" "css/rdi-demo.css" "js/rdi-demo.js" -}} + {{- $v = merge $v (dict . (substr (md5 (os.ReadFile (print "static/" .))) 0 10)) -}} +{{- end }} + + + + +
+ +
diff --git a/static/css/rdi-demo.css b/static/css/rdi-demo.css new file mode 100644 index 0000000000..de180693de --- /dev/null +++ b/static/css/rdi-demo.css @@ -0,0 +1,198 @@ +/* ========================================================================= + Redis Data Integration (RDI) scripted demo + Additions to context-retriever-demo.css, which the shortcode loads first + so the Iris demos share one look. Scoped under `.rdi`; the root element + also carries `.rcr` for the shared tokens and base styles. + ========================================================================= */ + +.rdi { --rdi-warn: #b35900; } +.rdi .rcr-tab.is-locked { opacity: .45; cursor: not-allowed; } +.rdi .rcr-btn:disabled { opacity: .5; cursor: default; } +.rdi .rcr-chip.is-on { border-color: var(--rcr-accent-2); color: var(--rcr-accent-2); background: rgba(89, 97, 255, .07); } +.rdi .rcr-chip:disabled.is-on { opacity: 1; } +.rdi .rdi-empty { color: var(--rcr-faint); font-size: 13px; } +.rdi .rdi-chip { + display: inline-block; font-size: 11px; font-weight: 600; color: var(--rcr-muted); + background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); border-radius: 999px; padding: 0 8px; white-space: nowrap; +} +.rdi .rdi-flag { + font-size: 10px; font-weight: 700; letter-spacing: .3px; color: var(--rcr-bad); background: rgba(198, 40, 40, .07); + border: 1px solid rgba(198, 40, 40, .3); border-radius: 999px; padding: 0 6px; white-space: nowrap; +} +.rdi .rcr-group summary .rdi-flag { margin-left: 4px; } + +/* ---- the pipeline card ---- */ +.rdi .rdi-pipe { margin-bottom: 10px; } +.rdi-status { display: inline-flex; align-items: center; gap: 2px; font-size: 12px; font-weight: 600; color: var(--rcr-muted); } +.rdi-status.is-streaming { color: var(--rcr-ok); } +.rdi-status .rcr-dot:not(.is-ok):not(.is-wait) { background: var(--rcr-line-strong); } +.rdi-flow { display: flex; flex-wrap: wrap; align-items: stretch; gap: 6px; margin: 4px 0 10px; } +.rdi-stage { + flex: 1 1 110px; min-width: 0; display: grid; gap: 1px; padding: 6px 9px; border-radius: 8px; + border: 1px solid var(--rcr-line); background: var(--rcr-surface-2); transition: background-color .25s, border-color .25s; +} +.rdi-stage.is-on { border-color: var(--rcr-accent-2); background: rgba(89, 97, 255, .08); } +.rdi-stagename { font-size: 12.5px; font-weight: 600; } +.rdi .rdi-stagesub { font-size: 10.5px; color: var(--rcr-faint); overflow-wrap: anywhere; } +.rdi-arrow { align-self: center; color: var(--rcr-line-strong); font-size: 14px; } +.rdi-arrow.is-on { color: var(--rcr-accent-2); } +/* On narrow screens the stages sit in a 2 x 2 grid of equal boxes, so the + arrows would point sideways at nothing. */ +@container (max-width: 559px) { + .rcr .rdi-flow { display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: 1fr; } + .rcr .rdi-arrow { display: none; } +} +.rdi-statswrap { overflow-x: auto; } +.rdi-stats { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; margin: 0; } +.rdi-stats th { text-align: right; color: var(--rcr-faint); font-size: 10px; font-weight: 600; text-transform: uppercase; padding: 3px 5px; border-bottom: 1px solid var(--rcr-line); white-space: nowrap; } +.rdi-stats td { text-align: right; padding: 3px 5px; border-bottom: 1px solid var(--rcr-line); } +.rdi-stats th:first-child, .rdi-stats td:first-child { text-align: left; } +.rdi .rdi-stats td.rcr-mono { font-size: 11.5px; white-space: nowrap; } +.rdi-stats tr:last-child td { border-bottom: 0; } +.rdi-stats tr.is-hot td { background: rgba(89, 97, 255, .07); } +.rdi-files { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 8px; } +.rdi-file { min-width: 0; } +.rdi-file[open] { flex-basis: 100%; } +.rdi-file > summary { cursor: pointer; font-size: 12.5px; color: var(--rcr-accent-2); } +.rdi-file > .rcr-code { margin-top: 6px; } +/* CLI output keeps its columns: it scrolls rather than wraps. */ +.rdi pre.rdi-cli { font-size: 11px; max-height: 320px; } + +/* ---- choosing the data structure ---- */ +.rdi-pick { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 12px; } +.rdi .rdi-pick .rcr-chip { padding: 3px 12px; } + +/* ---- source and target, side by side when there's room ---- */ +.rdi-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; align-items: start; } +@container (min-width: 560px) { .rcr .rdi-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } } +.rdi .rdi-source { border-top: 3px solid #336791; } +.rdi .rdi-redis { border-top: 3px solid var(--rcr-accent); } +.rdi .rcr-subtabs { margin-bottom: 8px; } +.rdi-count { font-weight: 600; color: var(--rcr-faint); font-size: 11.5px; } +.rdi-rowswrap { overflow: auto; max-height: 320px; border: 1px solid var(--rcr-line); border-radius: 8px; } +.rdi-rows { border-collapse: collapse; font-size: 12px; margin: 0; white-space: nowrap; } +.rdi-rows th { + position: sticky; top: 0; background: var(--rcr-surface-2); text-align: left; font-weight: 600; font-size: 11.5px; + padding: 4px 8px; border-bottom: 1px solid var(--rcr-line); +} +.rdi-ctype { display: block; font-weight: 400; font-size: 10px; color: var(--rcr-faint); } +.rdi-rows td { padding: 3px 8px; border-bottom: 1px solid var(--rcr-line); } +.rdi-rows td.is-num { text-align: right; font-variant-numeric: tabular-nums; } +.rdi-rows tr:last-child td { border-bottom: 0; } +.rdi-rows tr.is-changed td { background: rgba(89, 97, 255, .06); } +.rdi-rows tr.is-new td { background: rgba(26, 127, 75, .09); } +.rdi-rows tr.is-deleted td { background: rgba(198, 40, 40, .07); color: var(--rcr-bad); text-decoration: line-through; } +.rdi-rows td.is-set { background: rgba(89, 97, 255, .16); font-weight: 600; } +.rdi-sql { display: grid; gap: 3px; margin-top: 8px; font-size: 12px; } +.rdi .rdi-sql code { white-space: pre-wrap; overflow-wrap: anywhere; } + +.rdi .rdi-keylist { position: relative; max-height: 260px; border: 1px solid var(--rcr-line); border-radius: 8px; } +.rdi .rdi-keylist .rcr-key { font-size: 12px; } +.rdi-fresh { animation: rdi-in .9s ease-out; } +@keyframes rdi-in { from { background: rgba(26, 127, 75, .22); } to { background: transparent; } } +.rdi-value { margin-top: 10px; } +.rdi-valuehead { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-bottom: 6px; } +.rdi .rdi-valuekey { font-weight: 700; font-size: 12.5px; } +.rdi-chg { background: rgba(89, 97, 255, .14); border-radius: 3px; } +.rdi-hash { border-collapse: collapse; font-size: 12px; margin: 0; width: 100%; background: var(--rcr-code-bg); border: 1px solid var(--rcr-line); border-radius: 8px; } +.rdi-hash td { padding: 2px 10px; vertical-align: top; overflow-wrap: anywhere; } +.rdi-hash td:first-child { color: #3b44c9; white-space: nowrap; } +.rdi-hash td:last-child { color: #1a7f4b; } +.rdi .rdi-hash .rcr-mono { font-size: 11.5px; } + +/* ---- the Changes card: one row per change, three steps each ---- */ +.rdi .rdi-changes { margin-bottom: 12px; border-top: 3px solid var(--rcr-accent-2); } +.rdi-crow { border-bottom: 1px solid var(--rcr-line); } +.rdi-crow:last-child { border-bottom: 0; } +.rdi-crow > summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; padding: 6px 2px; font-size: 13px; min-width: 0; } +.rdi-crow > summary::-webkit-details-marker { display: none; } +.rdi-crow > summary::before { content: "\25B8"; color: var(--rcr-faint); font-size: 11px; } +.rdi-crow[open] > summary::before { content: "\25BE"; } +.rdi-cwhat { font-weight: 600; flex: none; } +.rdi .rdi-ckey { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--rcr-faint); font-size: 11.5px; } +.rdi-steps { margin: 0 0 10px; display: grid; gap: 6px; } +.rdi-step { + border: 1px solid var(--rcr-line); border-radius: 8px; padding: 7px 10px; background: #fff; + transition: opacity .25s, background-color .25s, border-color .25s; +} +.rdi-step.is-pending { opacity: .45; } +.rdi-step.is-active { border-color: var(--rcr-accent-2); background: rgba(89, 97, 255, .06); box-shadow: inset 3px 0 0 var(--rcr-accent-2); } +.rdi-stephead { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; font-size: 13px; font-weight: 600; } +.rdi-stepnum { + width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center; flex: none; + font-size: 10.5px; font-weight: 700; color: #fff; background: var(--rcr-faint); +} +.rdi-step.is-done .rdi-stepnum { background: var(--rcr-ok); } +.rdi-step.is-active .rdi-stepnum { background: var(--rcr-accent-2); } +.rdi-stepstage { font-size: 10.5px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase; color: var(--rcr-faint); } +.rdi-step.is-active .rdi-stepstage { color: var(--rcr-accent-2); } +.rdi-stepbody { display: grid; gap: 4px; margin: 4px 0 0 26px; font-size: 12.5px; color: var(--rcr-muted); min-width: 0; } +.rdi .rdi-stepsql { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11.5px; justify-self: start; } +.rdi .rdi-stepnote { font-size: 12px; } +.rdi-rec > .rdi-stepnote { margin-top: 4px; } +.rdi-diff { display: grid; gap: 2px; font-size: 12px; } +.rdi .rdi-diff .rcr-mono { font-size: 11.5px; overflow-wrap: anywhere; } +.rdi-dfield { color: #3b44c9; } +.rdi-from { color: var(--rcr-faint); text-decoration: line-through; } +.rdi-to { color: var(--rcr-ink); font-weight: 700; background: rgba(89, 97, 255, .1); border-radius: 3px; padding: 0 3px; } +.rdi-rec > summary { cursor: pointer; font-size: 12px; color: var(--rcr-accent-2); } +.rdi-rec > .rcr-code { margin-top: 4px; } +.rdi-op { font-size: 11px; font-weight: 700; border-radius: 999px; padding: 0 8px; border: 1px solid; white-space: nowrap; } +.rdi-op-c { color: var(--rcr-ok); background: rgba(26, 127, 75, .08); border-color: rgba(26, 127, 75, .3); } +.rdi-op-u { color: var(--rcr-accent-2); background: rgba(89, 97, 255, .08); border-color: rgba(89, 97, 255, .3); } +.rdi-op-d { color: var(--rcr-bad); background: rgba(198, 40, 40, .07); border-color: rgba(198, 40, 40, .3); } + +/* ---- choosing what happens next ---- */ +.rdi .rdi-composer { margin-bottom: 12px; background: var(--rcr-surface-2); } +.rdi .rdi-composer > .rcr-lbl { display: block; margin-bottom: 6px; } +.rdi-options { display: grid; gap: 6px; } +.rdi .rdi-option { display: grid; gap: 2px; text-align: left; border-radius: 10px; padding: 7px 12px; font-size: 13px; line-height: 1.4; } +.rdi-optwhat { font-weight: 600; } +.rdi-optdetail { color: var(--rcr-muted); font-size: 12.5px; } +.rdi .rdi-option code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11.5px; background: #fff; } +.rdi .rdi-option:disabled { opacity: 1; cursor: default; } +.rdi .rdi-option.is-done { border-color: var(--rcr-line); color: var(--rcr-faint); background: transparent; } +.rdi .rdi-option.is-done code { color: var(--rcr-faint); } +.rdi .rdi-option.is-later { opacity: .5; } +.rdi .rdi-option .rdi-stepnum { margin-right: 6px; vertical-align: 1px; background: var(--rcr-accent-2); } +.rdi .rdi-option.is-later .rdi-stepnum { background: var(--rcr-faint); } +.rdi-optq { color: var(--rcr-ink); font-size: 12.5px; } +.rdi[data-busy="true"] .rdi-option:not(.is-done) { opacity: .6; } +.rdi-tick { color: var(--rcr-ok); font-weight: 700; margin-right: 5px; } +.rdi-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } + +/* ---- the job file, with its two numbered parts ---- */ +.rdi .rdi-job { margin-bottom: 12px; } +/* YAML keeps its indentation: no wrapping, sideways scrolling when a line + doesn't fit, and no height limit, so the numbered parts are never + scrolled out of view. The body is as wide as the widest line, so a + highlighted block spans it. */ +.rdi pre.rdi-yaml { white-space: pre; font-size: 11.5px; max-height: none; } +.rdi-yamlbody { display: inline-block; min-width: 100%; vertical-align: top; } +.rdi-line { display: block; } +/* The job file has a left gutter for the numbers, so they stay in view when the file scrolls sideways. */ +.rdi-jobfile .rdi-line { padding-left: 26px; } +.rdi-jobfile .rdi-ann { position: relative; margin: 0 -12px; padding: 0 12px 0 38px; background: rgba(89, 97, 255, .08); box-shadow: inset 3px 0 0 var(--rcr-accent-2); } +.rdi-annmark { + width: 17px; height: 17px; border-radius: 50%; display: inline-grid; place-items: center; flex: none; + font-family: inherit; font-size: 10.5px; font-weight: 700; line-height: 1; color: #fff; background: var(--rcr-accent-2); +} +.rdi-ann > .rdi-annmark { position: absolute; left: 8px; top: 0; } +.rdi-annlist { display: grid; gap: 6px; margin-top: 8px; font-size: 13px; line-height: 1.5; } +.rdi-annlist > div { display: grid; grid-template-columns: 17px minmax(0, 1fr); column-gap: 8px; align-items: start; } +/* Centre each number on the first line of its text: half the difference between the line height and the number's size. */ +.rdi-annlist .rdi-annmark { margin-top: calc((13px * 1.5 - 17px) / 2); } + +/* ---- notes and controls ---- */ +.rdi .rdi-callout { + border-left: 3px solid var(--rcr-accent-2); background: var(--rcr-surface-2); border-radius: 0 8px 8px 0; + padding: 8px 12px; margin-top: 12px; font-size: 13.5px; +} +.rdi-callout p + p { margin-top: 6px !important; } +.rdi-controls { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; } +.rdi-next { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 4px 10px; text-align: right; } + +@media (prefers-reduced-motion: reduce) { + .rdi-fresh { animation: none; } + .rdi-stage, .rdi-step { transition: none; } +} diff --git a/static/js/rdi-demo.js b/static/js/rdi-demo.js new file mode 100644 index 0000000000..4232406e9f --- /dev/null +++ b/static/js/rdi-demo.js @@ -0,0 +1,985 @@ +/* ========================================================================= + Redis Data Integration (RDI) scripted demo + Dependency-free. Everything runs in the browser against a scripted + PostgreSQL database; no RDI pipeline or database is called. + + Step 1 deploys a pipeline for three PostgreSQL tables and plays the + initial sync into Redis, with default keys. Step 2 runs SQL statements the + reader picks and follows each change through the pipeline. Step 3 deploys + a job file for one table, then shows what changes when a row is updated, + when the pipeline is reset, and when the target is flushed. + + What follows the docs (content/integrate/redis-data-integration and + content/operate/rc/rdi): the default key pattern + tablename:primarykeyname:primarykeyvalue, hashes storing every value as a + string and JSON keeping numbers, opcodes r/c/u/d, the stream name + data:{rdi}:.., the config.yaml and job file + syntax, the redis-di status layout and its statistics columns, that a job + only affects newly captured data until a reset, that a reset doesn't + delete records from the target, and that flushing needs a stopped + pipeline. Modelled rather than observed: the data and the timings. + + Customers, restaurants, and orders are the food delivery sample data + from context-retriever-demo.js, as PostgreSQL rows. Keep them in sync. + + Base styles come from context-retriever-demo.css; rdi-demo.css adds the + pieces this demo needs. + ========================================================================= */ +(function () { + "use strict"; + if (window.RdiDemo) return; // loaded twice: the first copy already booted every widget + + /* ------------------------------------------------------------- constants */ + + var SOURCE = "postgresql", DB = "fooddelivery", SCHEMA = "public", PIPELINE = "default"; + var T0 = Date.UTC(2026, 8, 30, 9, 0, 0); // the pipeline is deployed at 09:00 UTC + + function ts(y, m, d, h) { return Math.floor(Date.UTC(y, m - 1, d, h == null ? 12 : h) / 1000); } + function rowsOf(columns, list) { + return list.map(function (r) { var o = {}; columns.forEach(function (c, i) { o[c[0]] = r[i]; }); return o; }); + } + function email(name) { return name.toLowerCase().replace(/[^a-z]+/g, ".") + "@example.com"; } + + var CUSTOMER_COLUMNS = [["id", "text"], ["name", "text"], ["email", "text"], ["city", "text"], ["dietary", "text"], + ["favorite_cuisine", "text"], ["loyalty_points", "integer"], ["joined_ts", "bigint"]]; + var RESTAURANT_COLUMNS = [["id", "text"], ["name", "text"], ["cuisine", "text"], ["city", "text"], + ["rating", "double precision"], ["price_level", "integer"], ["avg_delivery_min", "integer"]]; + var ORDER_COLUMNS = [["id", "text"], ["customer_id", "text"], ["restaurant_id", "text"], ["status", "text"], + ["total", "double precision"], ["placed_ts", "bigint"], ["items", "text"]]; + + var TABLES = [ + { name: "customers", columns: CUSTOMER_COLUMNS, rows: rowsOf(CUSTOMER_COLUMNS, [ + ["u101", "Maya Chen", "austin", "vegetarian", "thai", 1240, ts(2024, 5, 3)], + ["u102", "Liam Ortiz", "austin", "none", "bbq", 860, ts(2025, 1, 19)], + ["u103", "Priya Nair", "seattle", "vegan", "indian", 2310, ts(2023, 9, 12)], + ["u104", "Noah Williams", "denver", "none", "pizza", 410, ts(2025, 11, 2)], + ["u105", "Sofia Rossi", "seattle", "vegetarian", "italian", 1780, ts(2024, 2, 27)], + ["u106", "Ethan Brooks", "denver", "gluten_free", "sushi", 950, ts(2024, 8, 14)], + ["u107", "Aisha Khan", "austin", "halal", "mediterranean", 1530, ts(2023, 12, 6)], + ["u108", "Lucas Meyer", "denver", "none", "burgers", 120, ts(2026, 7, 21)] + ].map(function (r) { return [r[0], r[1], email(r[1])].concat(r.slice(2)); })) }, + { name: "restaurants", columns: RESTAURANT_COLUMNS, rows: rowsOf(RESTAURANT_COLUMNS, [ + ["r201", "Bangkok Street Kitchen", "thai", "austin", 4.7, 2, 30], + ["r202", "Smokehouse 512", "bbq", "austin", 4.4, 3, 40], + ["r203", "Green Bowl", "vegan", "seattle", 4.8, 2, 25], + ["r204", "Sakura Sushi Bar", "sushi", "denver", 4.6, 3, 35], + ["r205", "Nonna's Trattoria", "italian", "seattle", 4.5, 3, 45], + ["r206", "Slice Society", "pizza", "denver", 4.2, 1, 30], + ["r207", "Olive & Za'atar", "mediterranean", "austin", 4.6, 2, 30], + ["r208", "Spice Route", "indian", "seattle", 4.3, 2, 40], + ["r209", "Lotus Thai", "thai", "austin", 4.1, 1, 25] + ]) }, + { name: "orders", columns: ORDER_COLUMNS, rows: rowsOf(ORDER_COLUMNS, [ + ["o3001", "u101", "r201", "delivered", 34.5, ts(2026, 9, 2, 19), "Pad thai with tofu, spring rolls"], + ["o3002", "u101", "r207", "delivered", 28, ts(2026, 9, 10, 13), "Falafel wrap, hummus"], + ["o3003", "u101", "r209", "on_the_way", 22.75, ts(2026, 9, 24, 18), "Green curry without fish sauce, jasmine rice"], + ["o3004", "u102", "r202", "delivered", 61.2, ts(2026, 9, 5, 20), "Brisket platter, mac and cheese"], + ["o3005", "u102", "r201", "cancelled", 19.9, ts(2026, 8, 28, 12), "Pad see ew"], + ["o3006", "u103", "r203", "delivered", 24, ts(2026, 9, 12, 12), "Buddha bowl, oat latte"], + ["o3007", "u103", "r208", "delivered", 38.4, ts(2026, 9, 18, 19), "Chana masala, garlic naan"], + ["o3008", "u104", "r206", "delivered", 42, ts(2026, 9, 6, 20), "Two large pizzas"], + ["o3009", "u104", "r204", "delivered", 67.8, ts(2026, 9, 20, 19), "Omakase set for two"], + ["o3010", "u105", "r205", "delivered", 54.3, ts(2026, 9, 8, 20), "Mushroom risotto, tiramisu"], + ["o3011", "u105", "r203", "preparing", 21.5, ts(2026, 9, 24, 18), "Vegan lasagna"], + ["o3012", "u106", "r204", "delivered", 48.9, ts(2026, 9, 14, 19), "Salmon nigiri, miso soup"], + ["o3013", "u107", "r207", "delivered", 36.2, ts(2026, 9, 11, 13), "Chicken shawarma plate"], + ["o3014", "u107", "r201", "delivered", 26, ts(2026, 8, 30, 19), "Pad thai with chicken"], + ["o3015", "u108", "r206", "delivered", 18.5, ts(2026, 9, 15, 12), "Pepperoni slice combo"], + ["o3016", "u108", "r204", "on_the_way", 29.4, ts(2026, 9, 24, 18), "Spicy tuna roll, edamame"], + ["o3017", "u106", "r206", "delivered", 26, ts(2026, 9, 21, 20), "Gluten-free margherita"], + ["o3018", "u101", "r201", "delivered", 31, ts(2026, 9, 19, 19), "Pad thai with tofu, mango sticky rice"], + ["o3019", "u105", "r205", "delivered", 47.6, ts(2026, 9, 22, 20), "Eggplant parmigiana, focaccia"], + ["o3020", "u102", "r209", "delivered", 25.3, ts(2026, 9, 16, 13), "Drunken noodles, Thai iced tea"] + ]) } + ]; + var NUMERIC = { integer: true, bigint: true, "double precision": true }; + + /* ---- step 2: statements the reader runs against PostgreSQL ---- */ + + var REORDER = { id: "o3021", customer_id: "u101", restaurant_id: "r209", status: "preparing", total: 22.75, + placed_ts: ts(2026, 9, 30, 18), items: "Green curry without fish sauce, jasmine rice" }; + function sqlValue(v) { return typeof v === "number" ? String(v) : "'" + String(v).replace(/'/g, "''") + "'"; } + var STATEMENTS = [ + { key: "deliver", table: "orders", op: "u", id: "o3003", set: { status: "delivered" }, what: "Maya's green curry arrives." }, + { key: "reorder", table: "orders", op: "c", id: REORDER.id, row: REORDER, what: "Maya orders the curry again." }, + { key: "busy", table: "restaurants", op: "u", id: "r201", set: { avg_delivery_min: 45 }, what: "Bangkok Street Kitchen gets busy." }, + { key: "purge", table: "orders", op: "d", id: "o3005", what: "The app deletes a cancelled order." } + ]; + /* ---- step 3: the customers job, then applying it to existing data ---- */ + var POINTS = { key: "points", table: "customers", op: "u", id: "u101", set: { loyalty_points: 1290 }, what: "Maya earns 50 loyalty points." }; + /* In this order: each step answers the question the one before leaves open. */ + var ACTIONS = [ + { key: "points", label: "Update a customer", question: "Does the job change keys that already exist?", + detail: "UPDATE customers SET loyalty_points = 1290 WHERE id = 'u101';" }, + { key: "reset", label: "Reset the pipeline", question: "Does a new snapshot through the job clean up the old keys?", + detail: "RDI takes a new snapshot of every table." }, + { key: "flush", label: "Flush the target and reset", question: "How do you end up with only the new keys?", + detail: "Stop the pipeline, delete everything in the target database, then reset." } + ]; + function sqlOf(s) { + if (s.op === "c") return "INSERT INTO " + s.table + " VALUES (" + tableOf(s.table).columns.map(function (c) { return sqlValue(s.row[c[0]]); }).join(", ") + ");"; + if (s.op === "d") return "DELETE FROM " + s.table + " WHERE id = " + sqlValue(s.id) + ";"; + return "UPDATE " + s.table + " SET " + Object.keys(s.set).map(function (k) { return k + " = " + sqlValue(s.set[k]); }).join(", ") + + " WHERE id = " + sqlValue(s.id) + ";"; + } + /* RDI's opcodes use Debezium's operation names: c is create, which is + what an INSERT produces. The letters appear only where the demo shows + RDI's own data; everywhere else the operation is spelled out. */ + var OPCODES = { r: "read, for a snapshot row", c: "create, for an INSERT", u: "update", d: "delete" }; + var OPERATIONS = { r: "Snapshot", c: "Insert", u: "Update", d: "Delete" }; + var STATUS = { deploying: "Deploying", initial: "Initial sync", streaming: "Streaming", updating: "Updating", + resetting: "Resetting", stopped: "Stopped" }; + + /* --------------------------------------------------------------- helpers */ + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + function el(tag, cls, html) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; + } + function byKey(list, key) { return list.filter(function (x) { return x.key === key; })[0]; } + function tableOf(name) { return TABLES.filter(function (t) { return t.name === name; })[0]; } + function clone(o) { var c = {}; Object.keys(o).forEach(function (k) { c[k] = o[k]; }); return c; } + function plural(n, one, many) { return n + " " + (n === 1 ? one : (many || one + "s")); } + function hhmmss(ms) { return new Date(ms).toISOString().slice(11, 19); } + function iso(ms) { return new Date(ms).toISOString().slice(0, 19) + "Z"; } + function code(s) { return "" + esc(s) + ""; } + function streamName(table) { return "data:{rdi}:" + SOURCE + "." + SCHEMA + "." + table; } + function qualified(table) { return SOURCE + "." + SCHEMA + "." + table; } + + /* Pretty JSON with light syntax colouring (same as the other Iris demos). */ + function jsonHtml(v) { + var s = JSON.stringify(v, null, 2); + return esc(s).replace(/("(?:[^&]|&(?!quot;))*?")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?)/g, + function (m, str, colon, lit, num) { + if (str) return colon ? '' + str + "" + colon : '' + str + ""; + if (lit) return '' + lit + ""; + return '' + num + ""; + }); + } + + /* ------------------------------------------------------------ the model */ + + /* With no job file, the key is tablename:primarykeyname:primarykeyvalue. + The customers job writes customer:{id} instead. */ + function keyFor(table, id, job) { return job && table === "customers" ? "customer:" + id : table + ":id:" + id; } + /* Hashes store every value as a string; JSON keeps numbers as numbers. */ + function rowValue(table, row, type, dropEmail) { // dropEmail: the customers job's remove_field step + var v = {}; + tableOf(table).columns.forEach(function (c) { + if (dropEmail && c[0] === "email") return; + v[c[0]] = type === "hash" ? String(row[c[0]]) : row[c[0]]; + }); + return v; + } + + /* Everything the pipeline has done, as a list of records, from the + reader's choices so far. Rendering replays the first n of them, which is + also how the animations step through. */ + function records(st) { + var out = [], ids = {}, clock = T0; + TABLES.forEach(function (t) { ids[t.name] = t.rows.map(function (r) { return r.id; }); }); + function push(r, gap) { clock += gap; r.at = clock; out.push(r); } + function snapshot() { + push({ k: "status", v: "initial" }, 1500); + TABLES.forEach(function (t) { ids[t.name].forEach(function (id) { push({ k: "snap", table: t.name, id: id }, 90); }); }); + push({ k: "status", v: "streaming" }, 400); + } + function run(s) { + push({ k: "commit", stmt: s }, 0); + push({ k: "capture", stmt: s }, 180); + push({ k: "apply", stmt: s }, 240); + push({ k: "settled", stmt: s }, 0); + if (s.op === "c") ids[s.table].push(s.id); + if (s.op === "d") ids[s.table] = ids[s.table].filter(function (id) { return id !== s.id; }); + } + if (!st.deployed) return out; + push({ k: "status", v: "deploying" }, 0); + snapshot(); + st.ran.forEach(function (key, i) { clock = T0 + (12 + 3 * i) * 60000; run(byKey(STATEMENTS, key)); }); + if (st.jobDeployed) { + clock = T0 + 30 * 60000; + push({ k: "status", v: "updating" }, 0); + push({ k: "job" }, 2000); + push({ k: "status", v: "streaming" }, 1500); + } + st.actions.forEach(function (key, i) { + clock = T0 + (35 + 4 * i) * 60000; + if (key === "points") { run(POINTS); return; } + if (key === "flush") { push({ k: "status", v: "stopped" }, 0); push({ k: "flush" }, 3000); } + push({ k: "status", v: "resetting" }, key === "flush" ? 2000 : 0); + push({ k: "reset" }, 1000); + snapshot(); + }); + return out; + } + function zeroStats() { return { incoming: 0, pending: 0, inserted: 0, updated: 0, deleted: 0, filtered: 0, rejected: 0, last: null }; } + /* Replay the first n records: the source tables, the Redis target, the + counters, and the pipeline status at that point. */ + function apply(recs, n, opts) { + var src = {}, redis = {}, order = [], stats = {}, gone = {}, status = null, job = false, last = n ? recs[n - 1] : null, wrote = null; + var changes = {}; // per statement: the key it writes, the old values it replaces, and the row as captured + TABLES.forEach(function (t) { src[t.name] = t.rows.map(clone); stats[t.name] = zeroStats(); }); + function rowOf(table, id) { return src[table].filter(function (r) { return r.id === id; })[0]; } + function count(table, field, at) { var s = stats[table]; s.incoming++; s[field]++; s.last = at; } + function write(table, id, op, at) { + var key = keyFor(table, id, job), prev = redis[key] ? redis[key].value : null; + if (!redis[key]) order.push(key); + redis[key] = { key: key, table: table, id: id, type: opts.structure, op: op, at: at, prev: prev, + value: rowValue(table, rowOf(table, id), opts.structure, job && table === "customers") }; + wrote = key; + } + for (var i = 0; i < n; i++) { + var r = recs[i], s = r.stmt; + if (r.k === "status") status = r.v; + else if (r.k === "reset") TABLES.forEach(function (t) { stats[t.name] = zeroStats(); }); + else if (r.k === "job") job = true; + else if (r.k === "flush") { redis = {}; order = []; } + else if (r.k === "snap") { write(r.table, r.id, "r", r.at); count(r.table, "inserted", r.at); } + else if (r.k === "commit") { + var ch = changes[s.key] = { key: keyFor(s.table, s.id, job), job: job, before: {}, after: null }; + ch.existed = !!redis[ch.key]; // false when a job's new key pattern means this is the key's first write + if (s.op === "u") Object.keys(s.set).forEach(function (k) { ch.before[k] = rowOf(s.table, s.id)[k]; }); + if (s.op === "c") src[s.table].push(clone(s.row)); + else if (s.op === "d") { gone[s.table + ":" + s.id] = rowOf(s.table, s.id); src[s.table] = src[s.table].filter(function (x) { return x.id !== s.id; }); } + else { var row = rowOf(s.table, s.id); Object.keys(s.set).forEach(function (k) { row[k] = s.set[k]; }); } + if (s.op !== "d") ch.after = clone(rowOf(s.table, s.id)); + } else if (r.k === "apply") { + if (s.op === "d") { + var dk = keyFor(s.table, s.id, job); + delete redis[dk]; + order = order.filter(function (k) { return k !== dk; }); + count(s.table, "deleted", r.at); + } else { + write(s.table, s.id, s.op, r.at); + count(s.table, s.op === "c" ? "inserted" : "updated", r.at); + } + } + } + var keys = order.map(function (k) { return redis[k]; }); + keys.forEach(function (e) { e.leftover = job && e.table === "customers" && e.key.indexOf("customers:id:") === 0; }); + return { src: src, redis: redis, keys: keys, stats: stats, status: status, job: job, last: last, wrote: wrote, gone: gone, changes: changes }; + } + /* The latest statement, and how far through the pipeline it has got. */ + function latestStatement(recs, n) { + for (var i = n - 1; i >= 0; i--) { + var r = recs[i]; + if (r.stmt) return { stmt: r.stmt, stage: r.k === "commit" ? 1 : r.k === "capture" ? 2 : 3, settled: r.k === "settled" }; + if (r.k !== "status") return null; + } + return null; + } + + /* ---- configuration and status, as RDI shows them ---- */ + + function configYaml(structure) { + return [ + "sources:", + " " + SOURCE + ":", + " type: cdc", + " logging:", + " level: info", + " connection:", + " type: postgresql", + " host: ", + " port: 5432", + " database: " + DB, + " user: ${POSTGRESQL_DB_USERNAME}", + " password: ${POSTGRESQL_DB_PASSWORD}", + " schemas:", + " - " + SCHEMA, + " tables:" + ].concat(TABLES.map(function (t) { return " " + SCHEMA + "." + t.name + ": {}"; })).concat([ + "targets:", + " target:", + " connection:", + " type: redis", + " host: ", + " port: 6379", + " password: ${TARGET_DB_PASSWORD}", + "processors:", + " target_data_type: " + structure + ]).join("\n"); + } + function jobYaml(structure) { + return ["name: customers", "source:", " schema: " + SCHEMA, " table: customers", + "transform:", " - uses: remove_field", " with:", " fields:", " - field: email", + "output:", " - uses: redis.write", " with:", " connection: target", " data_type: " + structure, + " key:", " expression: concat(['customer:', id])", " language: jmespath"].join("\n"); + } + function pad(s, n) { s = String(s); while (s.length < n) s += " "; return s; } + function tableText(head, rows) { + var w = head.map(function (h, i) { return Math.max(h.length, Math.max.apply(null, rows.map(function (r) { return String(r[i]).length; }).concat([0]))); }); + function line(r) { return " " + r.map(function (c, i) { return pad(c, w[i]); }).join(" ").replace(/\s+$/, ""); } + return [line(head), line(head.map(function (h) { return h.replace(/./g, "-"); }))].concat(rows.map(line)).join("\n"); + } + /* The same sections and columns as `redis-di status` in the CLI reference. */ + function statusText(S) { + var mode = S.status === "streaming" ? "streaming" : S.status === "initial" ? "initial-sync" : "-"; + var out = ["Name: " + PIPELINE, "Active: yes", "Status: " + (S.status === "stopped" ? "stopped" : "started"), "Current: yes", "", + "Sources:", tableText(["Name", "Type", "Db Type", "Connection", "Sync Mode", "Connected"], + [[SOURCE, "cdc", "postgresql", ":5432", mode, "yes"]]), "", + "Targets:", tableText(["Name", "Db Type", "Connection", "Connected"], [["target", "redis", ":12000", "yes"]]), ""]; + if (S.job) { + out.push("Jobs:", tableText(["Name", "Server Name", "Db / Schema", "Table", "Transformations", "Outputs", "Connections"], + [["customers", SOURCE, SCHEMA, "customers", "1", "1", "target"]]), ""); + } + out.push("Statistics:", tableText(["Name", "Incoming", "Pending", "Inserted", "Updated", "Deleted", "Filtered", "Rejected", "Last Arrival"], + TABLES.map(function (t) { var s = S.stats[t.name]; + return [qualified(t.name), s.incoming, s.pending, s.inserted, s.updated, s.deleted, s.filtered, s.rejected, s.last ? iso(s.last) : "-"]; }))); + return out.join("\n"); + } + + /* ----------------------------------------------------------------- notes */ + + function structureNote(structure) { + return structure === "json" ? + "JSON keeps numbers as numbers, so a rating of 4.7 stays 4.7. Context Retriever reads JSON documents, so choose JSON if your agents use it." : + "Hashes store every value as a string, so a rating of 4.7 is stored as " + code('"4.7"') + ". Context Retriever reads only JSON documents."; + } + function syncNotes(st, S, done) { + if (!st.deployed) return ["RDI reads the tables you select and writes each row to Redis under its own key. Choose the default data structure, then deploy the pipeline.", + structureNote(st.structure)]; + if (!done && S.status === "deploying") return ["RDI validates the configuration, then starts the collector and the stream processor."]; + if (!done) return ["Initial sync: the collector reads a snapshot of every selected table, and the stream processor writes each row to Redis. " + + "Snapshot records have the opcode " + code("r") + "."]; + var rows = TABLES.reduce(function (n, t) { return n + t.rows.length; }, 0); + return ["Initial sync is done: " + rows + " rows became " + rows + " keys. With no job file, each key is the table name, the primary key column, " + + "and its value, such as " + code("restaurants:id:r201") + ".", + "The pipeline is now streaming. The collector captures every change committed to the selected tables, and RDI applies it to Redis, usually within a few seconds."]; + } + /* The three steps of one change, as the Changes card shows them. */ + function fieldValue(v, structure) { return structure === "hash" ? '"' + String(v) + '"' : JSON.stringify(v); } + function changeSteps(s, ch, structure) { + var key = code(ch.key), diff = s.op === "u" ? Object.keys(s.set).map(function (k) { return { field: k, from: ch.before[k], to: s.set[k] }; }) : []; + var first = s.op === "u" && !ch.existed; // an update that lands on a key the job's pattern creates + return [ + { stage: "PostgreSQL", title: "PostgreSQL commits the change.", sql: sqlOf(s), + diff: diff.map(function (d) { return { field: d.field, from: String(d.from), to: String(d.to) }; }), + text: s.op === "c" ? "A new row, " + code(s.id) + ", in " + code(s.table) + "." : s.op === "d" ? "Row " + code(s.id) + " is gone from " + code(s.table) + "." : "" }, + { stage: "Collector", title: "The collector captures it.", diff: [], + text: "It reads the change through PostgreSQL logical replication and adds it to the RDI stream " + code(streamName(s.table)) + + " with the opcode " + code(s.op) + " (" + OPCODES[s.op] + ").", + record: { opcode: s.op, db: DB, schema: SCHEMA, table: s.table, key: { id: s.id }, after: ch.after }, + note: ch.job && s.table === "customers" ? "The captured row still has " + code("email") + ": the job's " + code("remove_field") + + " step drops it before RDI writes the key." : "" }, + { stage: "Stream processor", title: "The stream processor writes it to Redis.", + text: first ? "It writes a new key, " + key + ", because the job changed the key pattern and removes " + code("email") + ". The old key " + + code(keyFor(s.table, s.id, false)) + " isn't touched." : + (s.op === "c" ? "It writes a new key, " + key + "." : s.op === "d" ? "It deletes the key " + key + "." : "It writes the row's new values to " + key + ".") + + (ch.job && s.table === "customers" ? " The job sets the key pattern and removes " + code("email") + "." : ""), + diff: first ? [] : diff.map(function (d) { return { field: d.field, from: fieldValue(d.from, structure), to: fieldValue(d.to, structure) }; }) } + ]; + } + function cdcNotes(st, L, done) { + if (!L) return ["The pipeline is streaming. Run a statement against PostgreSQL and follow it through the three steps in the Changes card. " + + "The app and its agents read only from Redis."]; + if (!done) return ["The highlighted step in the Changes card matches the highlighted box at the top. A change usually reaches Redis within a few seconds."]; + var s = L.stmt, notes = [s.op === "c" ? "A new row is a new key." : s.op === "d" ? "Deleting the row deletes its key: it's gone from Redis." : + "Only " + Object.keys(s.set).map(code).join(" and ") + " changed in PostgreSQL, so only that changed in Redis."]; + if (st.ran.length === STATEMENTS.length) notes.push("The counters in the pipeline card show what RDI applied to each table: " + + "inserts (snapshot reads count here too), updates, and deletes."); + return notes; + } + function jobNotes(st, S, done, L) { + var left = S.keys.filter(function (e) { return e.leftover; }).length; + if (!st.jobDeployed) return ["With no job file, every table gets default keys. A job file customizes one table. This one gives customers the key " + + "pattern " + code("customer:{id}") + ", the key template the Context Retriever demo reads." + + " It also removes the email column, so email addresses never reach Redis."]; + var lastAction = st.actions[st.actions.length - 1]; + if (!done) { + if (L && L.stmt === POINTS) return ["The update goes through the same three steps as in step 2, but now the stream processor applies the job."]; + if (S.status === "updating") return ["RDI validates the job and updates the stream processor."]; + if (S.status === "stopped") return ["Flushing needs a stopped pipeline. It deletes everything in the target database, including data written outside RDI."]; + return ["Reset: RDI clears its internal state and takes a new snapshot of every table, through the current job."]; + } + if (!lastAction) return ["The job is deployed, but all 8 customer keys are unchanged. A job only affects data captured after you deploy it, unless you reset the pipeline."]; + var notes, old = S.redis["customers:id:u101"]; + if (lastAction === "points") { + notes = ["The update went through the job, so RDI wrote " + code("customer:u101") + "." + (old ? " The old key " + code("customers:id:u101") + + " still has " + old.value.loyalty_points + " points, and nothing updates it anymore." : "")]; + } else if (lastAction === "reset") { + notes = ["The reset took a new snapshot through the job, so every customer has a " + code("customer:{id}") + " key. " + (left ? + "A reset doesn't delete records from the target, so the " + left + " old " + code("customers:id:") + (left === 1 ? " key is" : " keys are") + + " still there, out of date and with email addresses." : + "No old " + code("customers:id:") + " keys are left, because the target was flushed earlier.")]; + } else { + notes = ["The pipeline was stopped, the target flushed, and the reset reloaded every table, so only the new customer keys are left. " + + "Flushing deletes everything in the target database, including data written outside RDI."]; + } + if (lastAction === "flush" || st.actions.length === ACTIONS.length) { + notes.push("Give restaurants and orders a job too, with keys like " + code("restaurant:{id}") + ", and " + (st.structure === "json" ? + "the documents match the keys the Context Retriever demo reads." : "with JSON as the data structure, the documents match what the Context Retriever demo reads.")); + } + return notes; + } + + /* ------------------------------------------------------------------ view */ + + function init(root) { + var st = { tab: "sync", structure: "json", deployed: false, ran: [], jobDeployed: false, actions: [], + srcTab: "customers", sel: null, open: {}, anim: 0, busy: false, view: null }; + var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + function wait(ms) { return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); }); } + function opts() { return { structure: st.structure }; } + + root.innerHTML = ""; + var tabs = el("div", "rcr-tabs"); + tabs.setAttribute("role", "tablist"); + var TABS = [["sync", "1", "Initial sync"], ["cdc", "2", "Change data capture"], ["jobs", "3", "Job files"]]; + var panels = {}; + TABS.forEach(function (t, i) { + var b = el("button", "rcr-tab", '' + t[1] + "" + esc(t[2])); + b.type = "button"; b.setAttribute("role", "tab"); b.dataset.tab = t[0]; + b.setAttribute("aria-selected", i === 0 ? "true" : "false"); + b.addEventListener("click", function () { show(t[0]); }); + tabs.appendChild(b); + panels[t[0]] = el("div", "rcr-panel"); + panels[t[0]].setAttribute("role", "tabpanel"); + panels[t[0]].setAttribute("aria-live", "polite"); + if (i) panels[t[0]].hidden = true; + }); + root.appendChild(tabs); + Object.keys(panels).forEach(function (k) { root.appendChild(panels[k]); }); + + /* Animations replay records one at a time; the last step renders the + final state. Anything that changes the story bumps st.anim, so a + replay in flight stops and the panel shows the final state instead. */ + function cancel() { st.anim++; st.busy = false; st.view = null; } + function play(steps) { + var id = ++st.anim; + st.busy = true; + syncTabs(); + var chain = Promise.resolve(); + steps.forEach(function (s, i) { + chain = chain.then(function () { return wait(s[0]); }).then(function () { + if (id !== st.anim) return; + if (i === steps.length - 1) st.busy = false; + s[1](); + }); + }); + } + var GAP = { status: 700, snap: 70, commit: 0, capture: 1300, apply: 1300, settled: 1000, job: 1100, flush: 900, reset: 700 }; + /* Step from `from` records to all of them, following each record's + affected key and source table. `end.land` selects a key when the + replay finishes. `end.collapsed` selects nothing, so every key group + stays closed, as after the first deploy. */ + function replay(from, end) { + end = end || {}; + var R = records(st), steps = []; + for (var n = from + 1; n <= R.length; n++) { + (function (n) { + var r = R[n - 1], gap = GAP[r.k]; + if (r.k === "snap" && (n - from) % 3 && n !== R.length) return; // snapshots draw three rows at a time + /* The first frame draws at once, so a click shows a response straight away. */ + steps.push([steps.length ? gap * (r.k === "snap" ? 3 : 1) : 0, function () { + if (r.stmt) st.srcTab = r.stmt.table; + var S = apply(R, n, opts()); + if (r.k === "apply" && r.stmt.op === "d") st.sel = keyFor(r.stmt.table, r.stmt.id, S.job); + else if (r.k === "apply" || (r.k === "snap" && !end.collapsed)) st.sel = S.wrote; + if (n === R.length && end.land) st.sel = end.land; // end on the key the notes talk about + if (st.sel && (r.k === "apply" || r.k === "snap" || n === R.length)) delete st.open["g:" + prefixOf(st.sel)]; + draw(n === R.length ? null : { n: n }); + }]); + })(n); + } + play(steps); + } + function draw(view) { st.view = view; renderCurrent(); } + + /* Steps unlock in order. */ + function lockReason(name) { + var synced = st.deployed && !(st.busy && st.tab === "sync"); + if (name === "cdc" && !synced) return "Deploy the pipeline first."; + if (name === "jobs" && (!synced || st.ran.length < STATEMENTS.length || (st.busy && st.tab === "cdc"))) return "Run all four statements first."; + return null; + } + function syncTabs() { + root.setAttribute("data-busy", st.busy ? "true" : "false"); + Array.prototype.forEach.call(tabs.children, function (b) { + var why = lockReason(b.dataset.tab); + b.classList.toggle("is-locked", !!why); + if (why) { b.setAttribute("aria-disabled", "true"); b.title = why; } + else { b.removeAttribute("aria-disabled"); b.removeAttribute("title"); } + }); + } + function show(name) { + if (lockReason(name)) return; + cancel(); + st.tab = name; + Array.prototype.forEach.call(tabs.children, function (b) { b.setAttribute("aria-selected", b.dataset.tab === name ? "true" : "false"); }); + Object.keys(panels).forEach(function (k) { panels[k].hidden = k !== name; }); + renderCurrent(); + } + /* The Next and Restart buttons sit at the bottom of a long step, and the + step they open starts at the top. Scroll the tabs to just under the + site's sticky header, and move keyboard focus to the new tab so it isn't + lost with the replaced button. */ + function advance(name) { + show(name); + var header = document.querySelector("header"); + var under = header && /sticky|fixed/.test(getComputedStyle(header).position) ? header.getBoundingClientRect().bottom : 0; + window.scrollTo({ top: window.pageYOffset + tabs.getBoundingClientRect().top - under - 16, behavior: reduced ? "instant" : "smooth" }); + tabs.querySelector('[data-tab="' + name + '"]').focus({ preventScroll: true }); + } + function renderCurrent() { + var R = records(st), n = st.view ? st.view.n : R.length, S = apply(R, n, opts()), done = !st.view; + if (st.sel && !S.redis[st.sel] && done && !(S.last && S.last.stmt && S.last.stmt.op === "d")) st.sel = null; + var ctx = { R: R, n: n, S: S, done: done, L: latestStatement(R, n) }; + if (st.tab === "sync") renderSync(ctx); + else if (st.tab === "cdc") renderCdc(ctx); + else renderJobs(ctx); + reveal(panels[st.tab]); + syncTabs(); + } + /* The key list and the source rows scroll inside their cards, so bring + the selected key, and the row and cell the latest statement changed, + into view without moving the page. */ + function reveal(p) { + Array.prototype.forEach.call(p.querySelectorAll(".rdi-keylist"), function (list) { + var b = list.querySelector(".rcr-key.is-on"); + if (b) into(list, b, null); + }); + Array.prototype.forEach.call(p.querySelectorAll(".rdi-rowswrap"), function (w) { + var r = w.querySelector("tr.is-changed, tr.is-new, tr.is-deleted"); + if (r) into(w, r, w.querySelector("td.is-set")); + }); + } + function into(box, item, cell) { + var b = box.getBoundingClientRect(), e = item.getBoundingClientRect(), head = box.querySelector("thead"); + var top = head ? head.offsetHeight : 0; + if (e.top < b.top + top || e.bottom > b.bottom) box.scrollTop += e.top - b.top - top - (box.clientHeight - top - e.height) / 2; + if (cell) { + var c = cell.getBoundingClientRect(); + if (c.left < b.left || c.right > b.right) box.scrollLeft += c.left - b.left - (box.clientWidth - c.width) / 2; + } + } + function restart() { + cancel(); + st.structure = "json"; st.deployed = false; st.ran = []; st.jobDeployed = false; st.actions = []; + st.srcTab = "customers"; st.sel = null; st.open = {}; + advance("sync"); + } + + /* ---- shared pieces ---- */ + + function callout(notes) { + var c = el("div", "rdi-callout"); + notes.forEach(function (n) { c.appendChild(el("p", "", n)); }); + return c; + } + function controlsRow(primary) { + var row = el("div", "rdi-controls"); + var r = el("button", "rcr-btn", "Restart"); + r.type = "button"; + r.disabled = !st.deployed && st.tab === "sync"; + r.addEventListener("click", restart); + row.appendChild(r); + if (primary) { + /* `wait` keeps the button in view but disabled, with a hint saying why. */ + var end = el("div", "rdi-next"); + if (primary.wait) end.appendChild(el("span", "rcr-faint", esc(primary.wait))); + var b = el("button", "rcr-btn rcr-btn-primary", esc(primary.label)); + b.type = "button"; + b.disabled = st.busy || !!primary.wait; + b.addEventListener("click", primary.fn); + end.appendChild(b); + row.appendChild(end); + } + return row; + } + function remember(d, key, dflt) { + d.open = st.open[key] != null ? st.open[key] : !!dflt; + d.addEventListener("toggle", function () { st.open[key] = d.open; }); + return d; + } + function grid(a, b) { var g = el("div", "rdi-grid"); g.appendChild(a); g.appendChild(b); return g; } + function codeBlock(text) { return el("pre", "rcr-code rcr-code-sm", esc(text)); } + + /* The pipeline: status, the four stages, the counters, and its files. */ + function pipelineCard(ctx) { + var S = ctx.S, r = ctx.done ? null : S.last; + var card = el("div", "rcr-card rdi-pipe"); + var status = S.status || "none"; + card.appendChild(el("div", "rcr-cardhead", 'Pipeline ' + PIPELINE + "" + + ' ' + (STATUS[status] || "Not deployed") + "")); + /* Which stages light up, by index. A single change lights the stage it + has reached, and the arrows up to it. During a snapshot, rows pass + through every stage at once; the highlight steps through the stages + in order, one per frame, to show the direction the rows travel. */ + var on = {}, into = {}; + if (r && (r.k === "snap" || (r.k === "status" && r.v === "initial"))) { + /* Snapshot frames are three records apart, so count them from where + this snapshot started: its "initial" status draws PostgreSQL, and + each frame after it moves the highlight one stage on. */ + var start = ctx.n - 1; + while (start > 0 && !(ctx.R[start].k === "status" && ctx.R[start].v === "initial")) start--; + var k = r.k === "status" ? 0 : (1 + Math.floor((ctx.n - start - 2) / 3)) % 4; + on[k] = true; + into[k] = true; + } else if (r) { + var at = { commit: [0], capture: [1], apply: [2, 3], flush: [3] }[r.k] || []; + at.forEach(function (i) { on[i] = true; }); + for (var j = 1; j <= Math.max.apply(null, at.concat([0])); j++) into[j] = true; + } + var table = r && (r.stmt ? r.stmt.table : r.table); + /* Fixed text, so the boxes keep their size while records flow through. + The exact stream is named in the captured change and the notes. */ + var stages = [ + ["pg", "PostgreSQL", DB + "." + SCHEMA], + ["col", "Collector", "Debezium"], + ["proc", "Stream processor", streamName("*")], + ["redis", "Redis", "target"] + ]; + var flow = el("div", "rdi-flow"); + stages.forEach(function (s, i) { + if (i) flow.appendChild(el("span", "rdi-arrow" + (into[i] ? " is-on" : ""), "โ†’")); + flow.appendChild(el("div", "rdi-stage" + (on[i] ? " is-on" : ""), + '' + esc(s[1]) + '' + esc(s[2]) + "")); + }); + card.appendChild(flow); + var wrap = el("div", "rdi-statswrap"); + /* Pending and Filtered stay at 0 here, so the card leaves them out; redis-di status shows every column. */ + var t = "
" + + ""; + TABLES.forEach(function (tb) { + var s = S.stats[tb.name], hot = table === tb.name && r && (r.k === "apply" || r.k === "snap"); + t += ""; + }); + wrap.innerHTML = t + "
TableIncomingInsertedUpdatedDeletedRejected
' + esc(tb.name) + "" + s.incoming + + "" + s.inserted + "" + s.updated + "" + s.deleted + "" + s.rejected + "
"; + card.appendChild(wrap); + var files = el("div", "rdi-files"); + var cfg = remember(el("details", "rdi-file"), "config"); + cfg.appendChild(el("summary", "", "Show " + code("config.yaml"))); + cfg.appendChild(el("pre", "rcr-code rdi-yaml", '' + configYaml(st.structure).split("\n").map(function (l) { + return '' + esc(l) + ""; }).join("") + "")); + files.appendChild(cfg); + if (st.deployed && ctx.done) { + var stx = remember(el("details", "rdi-file"), "status"); + stx.appendChild(el("summary", "", "Show " + code("redis-di status"))); + stx.appendChild(el("pre", "rcr-code rdi-cli", esc(statusText(S)))); + files.appendChild(stx); + } + card.appendChild(files); + return card; + } + + /* The source tables, with the latest statement's row highlighted. */ + function sourceCard(ctx) { + var S = ctx.S, L = ctx.L, card = el("div", "rcr-card rdi-source"); + card.appendChild(el("div", "rcr-cardhead", 'PostgreSQL' + DB + "." + SCHEMA + "")); + var sub = el("div", "rcr-subtabs"); + sub.setAttribute("role", "tablist"); + TABLES.forEach(function (t) { + var b = el("button", "rcr-subtab", esc(t.name) + ' ' + S.src[t.name].length + ""); + b.type = "button"; b.setAttribute("role", "tab"); + b.setAttribute("aria-selected", st.srcTab === t.name ? "true" : "false"); + b.addEventListener("click", function () { st.srcTab = t.name; renderCurrent(); }); + sub.appendChild(b); + }); + card.appendChild(sub); + var t = tableOf(st.srcTab), rows = S.src[t.name].slice(); + var hot = L && L.stmt.table === t.name ? L.stmt : null; + if (hot && hot.op === "d" && L.stage < 3 && S.gone[t.name + ":" + hot.id]) { + var at = tableOf(t.name).rows.map(function (r) { return r.id; }).indexOf(hot.id); + rows.splice(Math.max(0, at), 0, S.gone[t.name + ":" + hot.id]); + } + var html = '' + t.columns.map(function (c) { + return '"; }).join("") + ""; + rows.forEach(function (r) { + var isHot = hot && r.id === hot.id, cls = isHot ? (hot.op === "d" ? "is-deleted" : hot.op === "c" ? "is-new" : "is-changed") : ""; + html += "" + t.columns.map(function (c) { + var changed = isHot && hot.op === "u" && hot.set[c[0]] != null; + return "" + esc(r[c[0]]) + ""; + }).join("") + ""; + }); + var scroll = el("div", "rdi-rowswrap", html + "
' + esc(c[0]) + '' + esc(c[1]) + "
"); + card.appendChild(scroll); + return card; + } + + /* Redis: keys grouped by pattern, and the selected key's value. */ + function prefixOf(key) { return key.replace(/[^:]+$/, ""); } + function redisCard(ctx) { + var S = ctx.S, card = el("div", "rcr-card rdi-redis"); + card.appendChild(el("div", "rcr-cardhead", 'Redis' + plural(S.keys.length, "key") + "")); + if (!S.keys.length) { + card.appendChild(el("p", "rdi-empty", st.deployed ? "The target database is empty." : "Empty. Deploy the pipeline to sync the tables.")); + return card; + } + var groups = [], byPrefix = {}; + S.keys.forEach(function (e) { + var p = prefixOf(e.key); + if (!byPrefix[p]) { byPrefix[p] = { prefix: p, keys: [] }; groups.push(byPrefix[p]); } + byPrefix[p].keys.push(e); + }); + var list = el("div", "rcr-keylist rdi-keylist"); + groups.forEach(function (g) { + var d = el("details", "rcr-group"); + var mine = st.sel && prefixOf(st.sel) === g.prefix, k = "g:" + g.prefix; + d.open = st.open[k] != null ? st.open[k] : mine; + var left = g.keys.some(function (e) { return e.leftover; }); + var sum = el("summary", "", '' + esc(g.prefix) + "* " + '' + g.keys.length + "" + + (left ? 'left over' : "") + '' + g.keys[0].type.toUpperCase() + ""); + sum.addEventListener("click", function () { st.open[k] = !d.open; }); + d.appendChild(sum); + g.keys.forEach(function (e) { + var b = el("button", "rcr-key rcr-mono" + (e.key === st.sel ? " is-on" : "") + (!ctx.done && e.key === S.wrote ? " rdi-fresh" : ""), esc(e.key)); + b.type = "button"; + b.addEventListener("click", function () { st.sel = e.key; st.open["g:" + g.prefix] = true; renderCurrent(); }); + d.appendChild(b); + }); + list.appendChild(d); + }); + card.appendChild(list); + card.appendChild(valueView(ctx)); + return card; + } + function valueView(ctx) { + var S = ctx.S, box = el("div", "rdi-value"), e = st.sel ? S.redis[st.sel] : null; + var L = ctx.L; + if (!e) { + var gone = L && L.stmt.op === "d" && L.stage === 3 ? keyFor(L.stmt.table, L.stmt.id, S.job) : null; + box.appendChild(el("p", "rdi-empty", gone ? code(gone) + " was deleted with its row." : "Select a key to see its value.")); + return box; + } + var latest = S.wrote === e.key && (ctx.L ? ctx.L.stage === 3 : true); + box.appendChild(el("div", "rdi-valuehead", '' + esc(e.key) + '' + + e.type.toUpperCase() + 'written ' + hhmmss(e.at) + " UTC ยท " + OPERATIONS[e.op].toLowerCase() + + " (opcode " + e.op + ")" + + (e.leftover ? 'left over' : ""))); + var changed = {}; + if (latest && e.prev) Object.keys(e.value).forEach(function (k) { if (e.prev[k] !== e.value[k]) changed[k] = true; }); + if (e.type === "json") { + var keys = Object.keys(e.value); + box.appendChild(el("pre", "rcr-code rcr-code-sm rdi-json", "{\n" + keys.map(function (k, i) { + var line = ' "' + esc(k) + "": " + jsonHtml(e.value[k]) + (i < keys.length - 1 ? "," : ""); + return changed[k] ? '' + line + "" : line; + }).join("\n") + "\n}")); + } else { + box.appendChild(el("table", "rdi-hash", Object.keys(e.value).map(function (k) { + return "' + esc(k) + '"' + esc(e.value[k]) + '"'; + }).join(""))); + } + return box; + } + /* Every change so far, one row each. The latest row is open and shows + the three steps, with the step the change has reached highlighted, in + step with the pipeline card. Earlier rows collapse to one line. */ + function changesCard(ctx, list, total, settled) { // settled: the latest change isn't the latest thing that happened + var L = ctx.L, card = el("div", "rcr-card rdi-changes"); + card.appendChild(el("div", "rcr-cardhead", "Changes" + (total ? '' + list.length + " of " + total + "" : ""))); + list.forEach(function (s, i) { + var ch = ctx.S.changes[s.key]; + if (!ch) return; + var current = i === list.length - 1 && !settled; + var stage = current && L && L.stmt === s && !ctx.done ? L.stage : 0; + card.appendChild(changeRow(s, ch, stage, current)); + }); + return card; + } + function changeRow(s, ch, stage, current) { + var k = s.key, d = el("details", "rdi-crow" + (current ? " is-current" : "")); + var sum = el("summary", "", '' + OPERATIONS[s.op] + "" + + '' + esc(s.what) + '' + esc(ch.key) + "" + + (stage ? '' : "")); + d.appendChild(sum); + /* Plain blocks, not a list or paragraphs, so the site's content styles + for numbered lists and paragraphs don't apply. */ + var steps = el("div", "rdi-steps"); + changeSteps(s, ch, st.structure).forEach(function (x, i) { + var state = !stage || i + 1 < stage ? "done" : i + 1 === stage ? "active" : "pending"; + var li = el("div", "rdi-step is-" + state); + li.appendChild(el("div", "rdi-stephead", '' + (i + 1) + '' + esc(x.stage) + + "" + esc(x.title))); + var body = el("div", "rdi-stepbody"); + if (x.sql) body.appendChild(el("code", "rdi-stepsql", esc(x.sql))); + if (x.text) body.appendChild(el("div", "rdi-steptext", x.text)); + if (x.diff.length) body.appendChild(el("div", "rdi-diff", x.diff.map(function (f) { + return '
' + esc(f.field) + ' ' + esc(f.from) + + ' โ†’ ' + esc(f.to) + "
"; + }).join(""))); + if (x.note) body.appendChild(el("div", "rdi-stepnote", x.note)); + if (x.record) { + var rec = remember(el("details", "rdi-rec"), "rec:" + k); + rec.appendChild(el("summary", "", "Show the captured change")); + rec.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(x.record))); + rec.appendChild(el("div", "rdi-stepnote", "A job with " + code("row_format: full") + " reads these fields. " + code("after") + " is " + + code("null") + " for a delete.")); + body.appendChild(rec); + } + li.appendChild(body); + steps.appendChild(li); + }); + d.appendChild(steps); + /* The latest row records only the reader's own clicks, so it collapses + like the others when the next change arrives. */ + if (current) { + d.open = st.open["now:" + k] !== false; + sum.addEventListener("click", function () { st.open["now:" + k] = !d.open; }); + } else remember(d, "chg:" + k); + return d; + } + /* The choices stay in place: finished ones are ticked and disabled, and + all of them are disabled while a change plays, so nothing jumps. With + `ordered`, they're numbered and only the next one can be chosen. */ + function optionsCard(label, items, isDone, render, pick, ordered) { + var c = el("div", "rcr-card rdi-composer"); + c.appendChild(el("span", "rcr-lbl", esc(label))); + var list = el("div", "rdi-options"); + var next = ordered ? items.filter(function (it) { return !isDone(it); })[0] : null; + items.forEach(function (it, i) { + var done = isDone(it), later = ordered && !done && it !== next; + var mark = done ? '' : ordered ? '' + (i + 1) + "" : ""; + var b = el("button", "rcr-chip rdi-option" + (done ? " is-done" : "") + (later ? " is-later" : ""), '' + + mark + esc(it.label || it.what) + (done ? ' (done)' : "") + "" + render(it)); + b.type = "button"; + b.disabled = done || later || st.busy; + if (later) b.title = "Do step " + (items.indexOf(next) + 1) + " first."; + b.addEventListener("click", function () { pick(it.key); }); + list.appendChild(b); + }); + c.appendChild(list); + return c; + } + + /* ---- tab 1: initial sync ---- */ + + function renderSync(ctx) { + var p = panels.sync; + p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "The food delivery app keeps its customers, restaurants, and orders in PostgreSQL. RDI copies them into Redis " + + "and keeps them in sync, so the app and its agents read from Redis instead. Choose how RDI stores each row, then deploy the pipeline.")); + p.appendChild(pipelineCard(ctx)); + var pick = el("div", "rdi-pick"); + pick.appendChild(el("span", "rcr-lbl", "Default data structure")); + [["json", "JSON"], ["hash", "Hash"]].forEach(function (o) { + var b = el("button", "rcr-chip" + (st.structure === o[0] ? " is-on" : ""), o[1]); + b.type = "button"; + b.setAttribute("aria-pressed", st.structure === o[0] ? "true" : "false"); + b.disabled = st.deployed; + b.addEventListener("click", function () { st.structure = o[0]; renderCurrent(); }); + pick.appendChild(b); + }); + if (st.deployed) pick.appendChild(el("span", "rcr-faint", "Set when the pipeline was deployed. Restart to change it.")); + p.appendChild(pick); + p.appendChild(grid(sourceCard(ctx), redisCard(ctx))); + p.appendChild(callout(syncNotes(st, ctx.S, ctx.done))); + p.appendChild(controlsRow(!st.deployed ? { label: "Deploy pipeline", fn: deploy } : + ctx.done ? { label: "Next: change data capture", fn: function () { advance("cdc"); } } : null)); + } + function deploy() { + if (st.busy || st.deployed) return; + st.deployed = true; + st.sel = null; + replay(0, { collapsed: true }); + } + + /* ---- tab 2: change data capture ---- */ + + function renderCdc(ctx) { + var p = panels.cdc; + p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "The pipeline is streaming. Run statements against PostgreSQL, in any order, and follow each change " + + "through the pipeline into Redis.")); + p.appendChild(pipelineCard(ctx)); + p.appendChild(optionsCard("Run a statement in PostgreSQL", STATEMENTS, function (s) { return st.ran.indexOf(s.key) >= 0; }, + function (s) { return "" + esc(sqlOf(s)) + ""; }, runStatement)); + var ran = st.ran.map(function (k) { return byKey(STATEMENTS, k); }); + if (ran.length) p.appendChild(changesCard(ctx, ran, STATEMENTS.length)); + p.appendChild(grid(sourceCard(ctx), redisCard(ctx))); + var L = ctx.L && ctx.L.stmt !== POINTS ? ctx.L : null; + p.appendChild(callout(cdcNotes(st, L, ctx.done))); + var left = STATEMENTS.length - st.ran.length; + p.appendChild(controlsRow({ label: "Next: job files", fn: function () { advance("jobs"); }, + wait: left ? "Run " + (left === STATEMENTS.length ? "all four statements" : left === 1 ? "the last statement" : "the other " + ["", "one", "two", "three"][left] + " statements") + " first." : null })); + } + function runStatement(key) { + if (st.busy || st.ran.indexOf(key) >= 0) return; + var from = records(st).length; + st.ran.push(key); + replay(from); + } + + /* ---- tab 3: job files ---- */ + + function renderJobs(ctx) { + var p = panels.jobs; + p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "Default keys like " + code("customers:id:u101") + " work, but your app may expect " + code("customer:u101") + + ". A job file customizes how RDI writes one table: its key, its data structure, and its fields.")); + p.appendChild(pipelineCard(ctx)); + var job = el("div", "rcr-card rdi-job"); + job.appendChild(el("div", "rcr-cardhead", 'jobs/customers.yaml' + + (st.jobDeployed ? "deployed" : "not deployed") + "")); + job.appendChild(jobFile(st.structure)); + p.appendChild(job); + if (st.jobDeployed) { + p.appendChild(optionsCard("Apply the job to existing data", ACTIONS, function (a) { return st.actions.indexOf(a.key) >= 0; }, function (a) { + return '' + esc(a.question) + "" + + (a.key === "points" ? "" + esc(a.detail) + "" : '' + esc(a.detail) + ""); + }, runAction, true)); + } + /* Once the reset or flush runs, the update is history, so its row collapses. */ + if (st.actions.indexOf("points") >= 0) p.appendChild(changesCard(ctx, [POINTS], 0, st.actions[st.actions.length - 1] !== "points")); + p.appendChild(grid(sourceCard(ctx), redisCard(ctx))); + p.appendChild(callout(jobNotes(st, ctx.S, ctx.done, ctx.L))); + p.appendChild(controlsRow(!st.jobDeployed ? { label: "Deploy the job", fn: deployJob } : null)); + } + /* The job file, with the two parts that differ from the default + highlighted and numbered, and what each one does underneath. */ + function jobFile(structure) { + var box = el("div", "rdi-jobfile"), block = 0, html = ""; + jobYaml(structure).split("\n").forEach(function (line) { + var start = /^transform:/.test(line) ? 1 : /^ key:/.test(line) ? 2 : 0; + if (start) block = start; + else if (/^output:/.test(line)) block = 0; + html += '' + (start ? '' + start + "" : "") + esc(line) + ""; + }); + /* YAML depends on indentation, so lines never wrap: the file scrolls + sideways instead, and the body spans the widest line so the + highlighted blocks reach across it. */ + box.appendChild(el("pre", "rcr-code rdi-yaml", '' + html + "")); + box.appendChild(el("div", "rdi-annlist", + '
1' + code("remove_field") + " drops the " + code("email") + + " column before RDI writes the row, so email addresses never reach Redis.
" + + '
2' + code("key") + " builds each key from the text " + code("customer:") + " and the row's " + + code("id") + ", so Maya's key is " + code("customer:u101") + ". Without a " + code("key") + " block, RDI uses its default pattern, " + + code("customers:id:u101") + ".
")); + return box; + } + function deployJob() { + if (st.busy || st.jobDeployed) return; + var from = records(st).length; + st.jobDeployed = true; + st.srcTab = "customers"; + replay(from); + } + function runAction(key) { + if (st.busy || st.actions.indexOf(key) >= 0) return; + var from = records(st).length; + st.actions.push(key); + st.srcTab = "customers"; + if (key !== "points") st.sel = null; + replay(from, { land: key === "points" ? null : "customer:u101" }); + } + + renderCurrent(); + } + + function boot() { Array.prototype.forEach.call(document.querySelectorAll(".rdi[data-rdi]"), init); } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot(); + + /* Exposed for tests. */ + window.RdiDemo = { TABLES: TABLES, STATEMENTS: STATEMENTS, ACTIONS: ACTIONS, POINTS: POINTS, records: records, apply: apply, + keyFor: keyFor, rowValue: rowValue, sqlOf: sqlOf, configYaml: configYaml, jobYaml: jobYaml, statusText: statusText, + syncNotes: syncNotes, cdcNotes: cdcNotes, jobNotes: jobNotes, latestStatement: latestStatement, changeSteps: changeSteps }; +})();