From 9a9650d16b4f2aff21532c9c78cfa3111aa9efbc Mon Sep 17 00:00:00 2001 From: Jon Currey Date: Mon, 11 May 2026 13:37:41 -0400 Subject: [PATCH 1/3] console: design doc for MZ Academy tutorial menu Adds a design doc describing how to convert the single Quickstart toggle in the SQL Shell header into an MZ Academy pull-down menu hosting multiple tutorials. --- .../20260510_mz_academy_tutorial_menu.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 console/doc/design/20260510_mz_academy_tutorial_menu.md diff --git a/console/doc/design/20260510_mz_academy_tutorial_menu.md b/console/doc/design/20260510_mz_academy_tutorial_menu.md new file mode 100644 index 0000000000000..e9a07b8c9f690 --- /dev/null +++ b/console/doc/design/20260510_mz_academy_tutorial_menu.md @@ -0,0 +1,214 @@ +# MZ Academy tutorial menu — design + +## Goal + +In the Materialize Console SQL Shell, replace the single "Quickstart" toggle in +the header with an "MZ Academy" pull-down menu. The menu lets the user pick +between multiple tutorials. For this iteration there are two: + +- **Quickstart** — the existing built-in tutorial (auction load generator). +- **MZ Academy: intro to Materialize** — a new tutorial that walks through + views, materialized views, indexes, idiomatic Materialize SQL, and temporal + filters against a pre-seeded e-commerce dataset (PostgreSQL CDC + a Kafka + clickstream). + +The framing is generic: this is useful for any individual learning Materialize +on their own as well as for a group running through the material together. + +## Constraints + +- The academy tutorial requires a Materialize emulator that has a PostgreSQL + source on the e-commerce schema, a Kafka clickstream source, and three user + clusters (`source_cluster`, `transform_cluster`, `serving_cluster`). Step 0 + of the tutorial describes what to verify; if the user runs the SQL without + the environment, Materialize surfaces helpful errors (object not found, + etc.) — we do not gate execution. +- Preserve the existing Quickstart behavior exactly when "Quickstart" is the + active tutorial. +- Match existing console patterns: Chakra UI components, jotai state, + TypeScript, file/style conventions used elsewhere in `console/src/`. + +## Approach + +Three approaches considered. + +### A. Single Menu replacing the Button +Replace the existing single button with a Chakra `Menu`. When closed the +`MenuButton` shows the currently selected tutorial name plus a chevron. Click +opens a menu with the tutorials; selecting one opens the panel. When the panel +is open the button morphs to "Close X". Pros: one affordance. Cons: the button +has two click meanings (open menu when closed, close panel when open) — that +flipping is confusing and breaks the discoverability of the picker once the +panel is open. + +### B. Split button — main toggle + chevron picker (chosen) +A small HStack: a primary Button to toggle the panel for the currently-selected +tutorial, plus an `IconButton` showing a chevron that opens a Menu listing the +tutorials. Pros: each control has a single, obvious meaning, and the chevron is +a clear signal that there's more than one tutorial. Cons: two adjacent header +controls instead of one. Recommendation: B. + +### C. Tabs inside the tutorial panel +Keep one button (renamed to "MZ Academy") that toggles the panel. Inside the +panel, put a `Tabs` at the top to pick the active tutorial. Pros: no new header +affordance. Cons: switching tutorials requires opening the panel first, so the +common case ("I want to start the academy course") costs an extra click and is +hidden until discovered. + +**Chosen: B.** Aligns with established split-button patterns (Chakra examples +under `console/src/platform/`) and keeps the existing one-click toggle behavior +intact. + +## Components + +### `store/tutorialIds.ts` (new) +Single source of truth for the set of tutorial IDs: + +```ts +export const TUTORIAL_IDS = ["quickstart", "academy"] as const; +export type TutorialId = (typeof TUTORIAL_IDS)[number]; + +export const TUTORIAL_LABELS: Record = { + quickstart: "Quickstart", + academy: "MZ Academy: intro to Materialize", +}; + +export const TUTORIAL_SHORT_LABELS: Record = { + quickstart: "Quickstart", + academy: "MZ Academy", +}; +``` + +### `store/shell.ts` (modified) +Extend `ShellState` with `activeTutorial: TutorialId`. Persist to +`localStorage` under `mz-shell-active-tutorial`. Default `quickstart` to +preserve existing first-run behavior. Switching tutorials resets +`currentTutorialStep` to 0. + +### `Tutorial.tsx` (refactored) +The existing module-level `stepsData` is the Quickstart's content. Refactor so +the file exports a `TUTORIALS` registry keyed by `TutorialId`, and the +`` component reads the active tutorial from `shellStateAtom` and +selects the right step array. The text label that today reads "QUICKSTART" +above the progress bar becomes the active tutorial's `TUTORIAL_SHORT_LABELS` +value, uppercased. + +Shared step primitives (`Runnable`, `TextContainer`, `RunnableContainer`, +`StepLayout`) move from `Tutorial.tsx` into a sibling `TutorialPrimitives.tsx` +so both tutorial step files can import them. + +### `AcademyTutorial.tsx` (new) +Exports `academyStepsData: StepData[]` with the following sections, each +written in the same prose style as the existing Quickstart (concise, runnable +SQL via the `` primitive). Topic coverage: + +1. **Introduction / environment check.** Explains that the tutorial assumes a + PostgreSQL CDC + Kafka clickstream environment already wired up. Names the + seed schemas and the expected sources/tables so the learner can verify by + running `SHOW SOURCES; SHOW TABLES;`. +2. **Clusters.** Three-cluster architecture (`source_cluster`, + `transform_cluster`, `serving_cluster`) and what each is for. +3. **Sources.** PG source + Kafka source overview; verify with `SHOW SOURCES`. +4. **Views.** Build `shopping_cart_line_item_subtotals`, + `shopping_cart_totals`, `shopping_cart_checkout`, + `current_totals_all_shopping_carts`. Explain re-computation cost. +5. **Materialized views.** Stack `product_sales` view under a + `product_performance` materialized view; compare `EXPLAIN` plans. +6. **Indexes — basics.** Index on a view + index on a materialized view; + point-lookup vs full-scan; cluster-local property. +7. **Indexes — reuse.** Index sharing within a cluster; creation order matters; + verify with `mz_internal.mz_materialization_dependencies`. +8. **Idiomatic SQL — top-K.** `LATERAL` + `ORDER BY LIMIT` over `RANK() OVER`; + `DISTINCT ON` for top-1. +9. **Idiomatic SQL — aggregate-as-window rewrites.** `COUNT(*) OVER (PARTITION + BY ...)` rewritten as a `GROUP BY` view + join. +10. **Temporal filters.** `mz_now()` constraints (comparison-only, AND-only in + materialized/indexed views); top-100-customers-in-last-minute example. +11. **Filter pushdown.** Filter-then-materialize vs materialize-then-filter; the + `pushdown=` annotation in `EXPLAIN MATERIALIZED VIEW`. +12. **MCP server.** Brief: the emulator serves `/api/mcp/developer` on port + 6876; link to docs for setting up a Claude Code / MCP client. (No + interactive MCP setup inside the tutorial — that's out of scope for a + web-based panel.) +13. **Summary / what's next.** Pointers to docs and patterns. + +All SQL strings are typed out fresh inline; the tutorial does not depend on +any external materials. + +### `ShellHeader.tsx` (modified) +Replace the single ` + + } … /> + + + {TUTORIAL_IDS.map(id => + selectTutorial(id)}> + {TUTORIAL_LABELS[id]} + )} + + + + +``` + +`selectTutorial(id)` updates `activeTutorial`, persists to localStorage, resets +`currentTutorialStep` to 0, and opens the panel. + +## Data flow + +``` +ShellHeader (dispatch tutorial select / toggle visibility) + ↓ +shellStateAtom (jotai) — { tutorialVisible, activeTutorial, currentTutorialStep } + ↓ +Tutorial.tsx — reads activeTutorial, looks up TUTORIALS[activeTutorial], + renders that stepsData via the existing Steps/Progress UI. +``` + +## Error handling + +- Tutorials run user SQL via the existing `runCommand` plumbing. Errors surface + in the shell history exactly as they do today (no new code path). +- LocalStorage-stored `activeTutorial` is validated against `TUTORIAL_IDS` on + load; if it's a stale/unknown value (e.g. an older build wrote something + else), we fall back to `quickstart`. + +## Testing + +- Existing console test suite (`yarn test`) must stay green. Specific files to + watch: `Shell.test.tsx`, `TutorialSchemaWidget.test.tsx`, + `TutorialInsertionWidget.test.tsx`. +- Manual browser smoke test against the running emulator using Playwright (or + manual): open the SQL Shell, verify default tutorial is Quickstart, switch to + MZ Academy, navigate steps, run a Runnable from a step. + +## Deployment to the running emulator + +For the duration of this work the materialized container is already running +with the stock console bundle at `/usr/share/nginx/html`. Iterating: + +1. `yarn build:local` in `console/` produces a fresh `dist/`. +2. `docker cp console/dist/. mz101-materialized:/usr/share/nginx/html/` swaps + the bundled console in the running container. nginx serves from disk so no + restart is needed. + +A full release would re-build the `materialized` mzbuild image with the new +console, which is out of scope for the AFK exercise. + +## Out of scope (YAGNI) + +- User-authored tutorials. +- Server-side tutorial registry / dynamically-loaded tutorial content. +- Per-tutorial completion tracking (we keep a single `currentTutorialStep`, + reset on switch). +- Localized strings. +- New theming for the menu/header. +- Interactive MCP setup inside the academy panel. From aae237c927946cd1690216ea2dfc2664d65f220e Mon Sep 17 00:00:00 2001 From: Jon Currey Date: Mon, 11 May 2026 13:37:49 -0400 Subject: [PATCH 2/3] console: turn Quickstart button into MZ Academy tutorial picker Adds a chevron Menu next to the existing tutorial toggle in the SQL Shell header. The menu lets the user choose between the Quickstart and a new MZ Academy: intro to Materialize tutorial. The choice persists in localStorage; the open/close toggle and step state continue to live on the existing shellStateAtom. Refactor: - Extract Runnable, TextContainer, RunnableContainer, StepLayout, and the step-data type into tutorialUtils.tsx. - Move the existing quickstart step array into quickstartSteps.tsx. - Add academySteps.tsx with 13 steps covering clusters, views, materialized views, indexes, index reuse, idiomatic Materialize SQL patterns (top-K, aggregate rewrites), temporal filters with mz_now(), filter-pushdown vs materialize-then-filter, and a pointer to the built-in /api/mcp/developer MCP server. The SQL targets an e-commerce schema fed by a PostgreSQL CDC source plus a Kafka clickstream source. - Tutorial.tsx becomes a thin harness that dispatches to the right step array based on shellState.activeTutorial. Behavior: when "Quickstart" is the active tutorial the panel is byte-for- byte identical to today. --- console/src/platform/shell/ShellHeader.tsx | 121 +- console/src/platform/shell/Tutorial.tsx | 1306 +---------------- console/src/platform/shell/academySteps.tsx | 1177 +++++++++++++++ .../src/platform/shell/quickstartSteps.tsx | 1207 +++++++++++++++ console/src/platform/shell/store/shell.ts | 20 + .../src/platform/shell/store/tutorialIds.ts | 21 + console/src/platform/shell/tutorialUtils.tsx | 114 ++ 7 files changed, 2658 insertions(+), 1308 deletions(-) create mode 100644 console/src/platform/shell/academySteps.tsx create mode 100644 console/src/platform/shell/quickstartSteps.tsx create mode 100644 console/src/platform/shell/store/tutorialIds.ts create mode 100644 console/src/platform/shell/tutorialUtils.tsx diff --git a/console/src/platform/shell/ShellHeader.tsx b/console/src/platform/shell/ShellHeader.tsx index 67bbb46e01986..aabc4c408547b 100644 --- a/console/src/platform/shell/ShellHeader.tsx +++ b/console/src/platform/shell/ShellHeader.tsx @@ -12,6 +12,12 @@ import { Button, GridItem, HStack, + IconButton, + Menu, + MenuButton, + MenuItem, + MenuList, + Portal, Text, useTheme, VStack, @@ -30,11 +36,22 @@ import SchemaSelect, { SchemaOption } from "~/components/SchemaSelect"; import { useAllClusters } from "~/store/allClusters"; import { useAllSchemas } from "~/store/allSchemas"; import BookOpenIcon from "~/svg/BookOpenIcon"; +import ChevronDownIcon from "~/svg/ChevronDownIcon"; import { MaterializeTheme } from "~/theme"; import ClusterDropdown from "./ClusterDropdown"; import { NAVBAR_HEIGHT_PX } from "./constants"; -import { setStoredSidebarVisibility, shellStateAtom } from "./store/shell"; +import { + setStoredActiveTutorial, + setStoredSidebarVisibility, + shellStateAtom, +} from "./store/shell"; +import { + TUTORIAL_IDS, + TUTORIAL_LABELS, + TUTORIAL_SHORT_LABELS, + TutorialId, +} from "./store/tutorialIds"; import { getSelectedSchemaOption } from "./utils"; function createSchemaOptionSelectionCommand( @@ -157,6 +174,22 @@ const ShellHeader = ({ setStoredSidebarVisibility(tutorialVisible); }; + const selectTutorial = (id: TutorialId) => { + setShellState((prevState) => ({ + ...prevState, + tutorialVisible: true, + activeTutorial: id, + // Reset to the first step when switching tutorials so the user lands on + // the intro page rather than partway through a different sequence. + currentTutorialStep: + prevState.activeTutorial === id ? prevState.currentTutorialStep : 0, + })); + setStoredSidebarVisibility(true); + setStoredActiveTutorial(id); + }; + + const activeTutorialLabel = TUTORIAL_SHORT_LABELS[shellState.activeTutorial]; + return ( @@ -191,32 +224,66 @@ const ShellHeader = ({ menuWidth="400px" /> - + + + + } + _focus={{ + border: `1px solid ${colors.accent.brightPurple}`, + boxShadow: shadows.input.focus, + }} + /> + + + {TUTORIAL_IDS.map((id) => ( + selectTutorial(id)} + fontWeight={ + id === shellState.activeTutorial ? "600" : "400" + } + > + {TUTORIAL_LABELS[id]} + + ))} + + + + {!isClustersError && // Need to make sure the cluster has loaded before checking if it exists diff --git a/console/src/platform/shell/Tutorial.tsx b/console/src/platform/shell/Tutorial.tsx index de790628eed2b..bb32c6e37ce89 100644 --- a/console/src/platform/shell/Tutorial.tsx +++ b/console/src/platform/shell/Tutorial.tsx @@ -9,1309 +9,44 @@ import { Box, - BoxProps, Button, - ChakraTheme, - Code, GridItem, GridItemProps, HStack, - IconButton, - ListItem, - OrderedList, StackProps, - Table, - Tbody, - Td, Text, - Th, - Thead, - Tr, - UnorderedList, useTheme, VStack, } from "@chakra-ui/react"; import { useAtom } from "jotai"; -import { useAtomCallback } from "jotai/utils"; -import React, { forwardRef, PropsWithChildren, useRef } from "react"; +import React, { forwardRef, useRef } from "react"; import { Link } from "react-router-dom"; import { useSegment } from "~/analytics/segment"; -import { TabbedCodeBlock } from "~/components/copyableComponents"; import ScheduleDemoLink from "~/components/ScheduleDemoLink"; -import TextLink from "~/components/TextLink"; -import docUrls from "~/mz-doc-urls.json"; import { newConnectionPath } from "~/platform/routeHelpers"; import { useRegionSlug } from "~/store/environments"; -import CommandIcon from "~/svg/CommandIcon"; -import { MaterializeTheme, ThemeColors } from "~/theme"; -import ColorsType from "~/theme/colors"; +import { MaterializeTheme } from "~/theme"; -import { saveClearPrompt, setPromptValue } from "./store/prompt"; +import { academyStepsData } from "./academySteps"; +import { quickstartStepsData } from "./quickstartSteps"; import { shellStateAtom } from "./store/shell"; -import TutorialInsertionWidget from "./TutorialInsertionWidget"; -import TutorialSchemaWidget from "./TutorialSchemaWidget"; +import { TUTORIAL_SHORT_LABELS, TutorialId } from "./store/tutorialIds"; +import { StepData, StepLayout } from "./tutorialUtils"; /* Semantic version number used for instrumentation */ const QUICKSTART_VERSION = "2.0.0"; -type RunnableProps = { - runCommand: (value: string) => void; - title: string; - value: string; +const TUTORIALS: Record = { + quickstart: quickstartStepsData, + academy: academyStepsData, }; -const Runnable = ({ runCommand, value, title }: RunnableProps) => { - const { colors } = useTheme(); - const setPrompt = useAtomCallback(setPromptValue); - const clearPrompt = useAtomCallback(saveClearPrompt); - - return ( - { - setPrompt(value); - runCommand(value); - clearPrompt(); - }} - icon={} - variant="unstyled" - rounded={0} - sx={{ - _hover: { - background: "rgba(255, 255, 255, 0.06)", - }, - }} - /> - } - /> - ); -}; - -const TextContainer = ({ children }: PropsWithChildren) => ( - - {children} - -); - -const RunnableContainer = ({ - children, - ...rest -}: PropsWithChildren & BoxProps) => ( - - {children} - -); - -const StepLayout = forwardRef( - ({ children, ...rest }: PropsWithChildren & BoxProps, ref) => ( - - {children} - - ), -); - -type StepProps = { - runCommand: (value: string) => void; - title: string; - colors: ChakraTheme["colors"] & ThemeColors & typeof ColorsType; -}; - -const stepsData: Array<{ - title: string; - render: (props: StepProps) => JSX.Element; -}> = [ - { - title: "Introduction", - render: ({ runCommand, title }) => ( - <> - - {title} - - Materialize provides always-fresh results while also providing - strong consistency guarantees. In Materialize, both{" "} - - indexes on views - {" "} - and{" "} - - materialized views - {" "} - incrementally update results when Materialize ingests new data, - meaning that work is performed on writes. - - In the quickstart, you will: - - - Create and query various views on sample auction data. The data is - continually generated at 1 second intervals to mimic a - data-intensive workload. - - - Create an index on a view to compute and store view results in - memory. As new auction data arrives, the index incrementally - updates view results. - - - Create and query views to verify that Materialize always serves - consistent results. - - - - - ), - }, - { - title: "Create a schema.", - render: ({ runCommand, title }) => ( - <> - - {title} - - By default, you are working in the{" "} - materialize.public{" "} - - namespace - {" "} - where: - - - - materialize is the database - name and - - - public is the schema name. - - - - - - - Create a separate schema for this quickstart. For a schema name to - be valid: - - - - The first character must be either: an ASCII letter (a-z and - A-Z), an underscore (_), or a non-ASCII character. - - - The remaining characters can be: an ASCII letter (a-z and A-Z), - ASCII numbers (0-9), an underscore (_), dollar signs ($), or a - non-ASCII character. - - - - Alternatively, by double-quoting the name, you can bypass the - aforementioned constraints with the following exception: schema - names, whether double-quoted or not, cannot contain the dot ( - .) See also{" "} - - Identifier: Naming restrictions - {" "} - - - - - - - - Switch to the new schema. From the top of the SQL Shell, select your - schema from the namespace dropdown. - - - - ), - }, - { - title: "Create the source.", - render: ({ runCommand, title }) => ( - <> - - {title} - - - Sources - {" "} - are external systems from which Materialize reads in data. This - tutorial uses Materialize's sample{" "} - - Auction load generator - {" "} - to create the sources. - - - - - - Create the source using the{" "} - - - CREATE SOURCE - - {" "} - command. For the sample load generator, the quickstart uses{" "} - - - CREATE SOURCE - - {" "} - with the FROM LOAD GENERATOR{" "} - clause that works specifically with Materialize's sample data - generators. The tutorial specifies that the generator should emit - new data every 1s. - - - - - - CREATE SOURCE - - {" "} - can create multiple tables (referred to as - subsources in Materialize) when - ingesting data from multiple upstream tables. For each upstream - table that is selected for ingestion, Materialize creates a - subsource. - - - Use{" "} - - - SHOW SOURCES - - {" "} - command to see the results of the previous step. - - - - A subsource is how Materialize refers to a table that has the - following properties: - - - A subsource can only be written by the source; in this case, the - load-generator. - - Users can read from subsources. - - - - - Use the{" "} - - - SELECT - - {" "} - statement to query auctions and{" "} - bids. - - - - - - - - Subsequent steps in this quickstart uses a query to find winning - bids for auctions to show how Materialize uses views and indexes - to provide immediately available up-to-date results for various - queries. - - - - - - ), - }, - { - title: "Create a view to find winning bids.", - render: ({ runCommand, title }) => ( - <> - - {title} - - A{" "} - - view - {" "} - is a saved name for the underlying{" "} - - - SELECT{" "} - - {" "} - statement, providing an alias/shorthand when referencing the query. - The underlying query is not executed during the view creation; - instead, the underlying query is executed when the view is - referenced. - - - Assume you want to find the winning bids for auctions that have - ended. The winning bid for an auction is the highest bid entered for - an auction before the auction ended. As new auction and bid data - appears, the query must be rerun to get up-to-date results. - - - - - - Use{" "} - - - CREATE VIEW - - {" "} - to create a view for the winning bids. Materialize provides an - idiomatic way to perform{" "} - - Top-K queries - - . For K = 1, the idiomatic Materialize SQL uses - - {" "} - - DISTINCT ON (...) - - {" "} - to return only the first row for each distinct auction id. - - = a.end_time - ORDER BY a.id, - b.amount DESC, - b.bid_time, - b.buyer;`} - title="Create a view." - /> - - - - - - - SELECT - - {" "} - from the view to execute the underlying query. For example: - - - - - - - Since new data is continually being ingested, you must{" "} - rerun the query to - get the up-to-date results. Each time you query the view, you - are re-running the underlying statement, which becomes less - performant as the amount of data grows. - - - In Materialize, to make the queries more performant even as data - continues to grow, you can create{" "} - - indexes - {" "} - on views. Indexes provide always fresh view results in memory - within a cluster by performing{" "} - - incremental updates as new data arrives - - . Queries can then read from the in-memory, already up-to-date - results instead of re-running the underlying statement, making - queries{" "} - - computationally free and more performant - - . - - - In the next step, you will create an index on{" "} - winning_bids. - - - - - - ), - }, - { - title: "Create an index to provide up-to-date results.", - render: ({ runCommand, title }) => ( - <> - - {title} - - Indexes in Materialize represent query results stored in memory - within a cluster. In Materialize, you can create{" "} - - indexes - {" "} - on views to provide always fresh view results in memory within a - cluster. Queries can then read from the in-memory, already - up-to-date results instead of re-running the underlying statement. - - - To provide the up-to-date results, indexes - - {" "} - perform incremental updates{" "} - {" "} - as inputs change instead of recalculating the results from scratch. - Additionally, indexes can also help{" "} - - optimize operations - {" "} - like point lookups and joins. - - - - - - Use the{" "} - - - CREATE INDEX - - {" "} - command to create the following index on the - winning_bids view. - - - - - - During the index creation, the underlying{" "} - winning_bids query is - executed, and the view results are stored in memory within the - cluster.{" "} - - As new data arrives, the index incrementally updates the view - results in memory.{" "} - {" "} - Because incremental work is performed on writes, reads from - indexes return up-to-date results and are computationally free. - - - The index can also help{" "} - - optimize - {" "} - operations like point lookups and{" "} - - delta joins{" "} - - on the index column(s) as well as support ad-hoc queries. - - - - - Rerun the previous queries on{" "} - winning_bids. - - - - - - The queries should be faster since they use the in-memory, already - up-to-date results computed by the index. - - - - - ), - }, - { - title: "Create views and a table to find flippers in real time.", - render: ({ runCommand, title }) => ( - <> - - {title} - - For this quickstart, auction flipping activities are defined as when - a user buys an item in one auction and resells the same item at a - higher price within an 8-day period. This step finds auction - flippers in real time, based on auction flipping activity data and - known flippers data. Specifically, this step creates: - - - - - A view to find auction flipping activities. Results are updated as - new data comes in (at 1 second intervals) from the data generator. - - - A table that maintains known auction flippers. You will manually - enter new data to this table. - - - A view to immediately see auction flippers based on either the - flipping activities view or the known auction flippers table. - - - - - - - Create a view to detect auction flipping activities. - - w1.amount - AND datediff('days',w2.bid_time,w1.bid_time) < 8 -;`} - title="Create a view flip_activities" - /> - - - The flip_activities view - can use the index created on - winning_bids view to - provide up-to-date data. - - - To view a sample row in{" "} - flip_activities, run the - following{" "} - - - SELECT{" "} - - {" "} - command: - - - - - - Use{" "} - - - CREATE TABLE{" "} - - {" "} - to create a known_flippers{" "} - table that you can manually populate with known flippers. That is, - assume that separate from your auction activities data, you receive - independent data specifying users as flippers. - - - - - - Create a view flippers to - immediately see flippers if either: - - A user has 2 or more flipping activities; or - - A user is listed in the - known_flippers table. - - - - = 2 - - UNION ALL - - SELECT flipper_id - FROM known_flippers -);`} - title="Create a view flippers" - /> - - - - - Both the known_flippers and{" "} - flippers views can use the index - created on winning_bids view to - provide up-to-date data. Depending upon your query patterns and usage, - an existing index may be sufficient, such as in this quickstart. In - other use cases, creating an index only on the view(s) from which you - will serve results may be preferred. - - - ), - }, - { - title: "Subscribe to see results change.", - render: ({ runCommand, title }) => ( - <> - - {title} - - - - SUBSCRIBE{" "} - - {" "} - to flippers to see new flippers - appear as new data arrives (either from the{" "} - known_flippers table or the{" "} - flip_activities view). - - - - - - Use{" "} - - - SUBSCRIBE - - {" "} - to see flippers as new data arrives (either from the{" "} - known_flippers table or the{" "} - flip_activities view). - - - - The optional{" "} - WITH (snapshot = false){" "} - indicates that the command displays only the new flippers that come - in after the start of the{" "} - - - SUBSCRIBE - - {" "} - operation, and not the flippers in the view at the start of the - operation. - - - - Enter a flipper id into the{" "} - known_flippers table. You can - specify any number for the flipper id. - - - - - - The flipper should immediately appear in the{" "} - - - SUBSCRIBE - - {" "} - results. You should also see flippers who are flagged by their flip - activities. Because of the randomness of the auction data being - generated, user activity data that match the definition of a flipper - may take some time even though auction data is constantly being - ingested. However, once new matching data comes in, you will see it - immediately in the{" "} - - - SUBSCRIBE - - {" "} - results. While waiting, you can enter additional flippers into the{" "} - known_flippers table. - - - To cancel out of the - - - SUBSCRIBE{" "} - - - , click the{" "} - Stop streaming button. - - - - ), - }, - { - title: "Verify that Materialize returns consistent data.", - render: ({ runCommand, title }) => ( - <> - - {title} - - - To verify that Materialize serves consistent results, even as new - data comes in, this step creates the following views for completed - auctions: - - - - - A view to keep track of each seller's credits. - - - A view to keep track of each buyer's debits. - - - A view that sums all sellers' credits, all buyers' - debits, and calculates the difference, which should be 0. - - - - - - - Create a view to track credited amounts for sellers of completed - auctions. - - - - - - Create a view to track debited amounts for the winning bidders of - completed auctions. - - - - - - To verify that the total credit and total debit amounts equal for - completed auctions (i.e., to verify that the results are correct and - consistent even as new data comes in), create a{" "} - funds_movement view that - calculates the total credits across sellers, total debits across - buyers, and the difference between the two. - - - - - - To see that the sums always equal even as new data comes in, you can{" "} - - - SUBSCRIBE - - {" "} - to this query: - - - - Toggle Show diffs to see - changes to funds_movement. As - new data comes in and auctions complete, the{" "} - total_credits and - total_debits values should - change but the total_difference - should remain 0. - - - To cancel out of the{" "} - - - SUBSCRIBE - - - , click the{" "} - Stop streaming button. - - - - ), - }, - { - title: "Clean up.", - render: ({ runCommand, title }) => ( - <> - - {title} - - To clean up the quickstart environment: - - - - - Use{" "} - - - DROP SOURCE - - {" "} - with the CASCADE option to - drop auction_house and its - dependent objects (e.g., views and indexes). - - - - Use{" "} - - {" "} - - DROP TABLE - - {" "} - to drop the known_flippers{" "} - table - - - - - - - - ), - }, - { - title: "Summary", - render: ({ runCommand, title }) => ( - <> - - {title} - - In Materialize,{" "} - - indexes - {" "} - represent query results stored in memory within a cluster. When you - create an index on a view, the index incrementally updates the view - results (instead of recalculating the results from scratch) as - Materialize ingests new data. These up-to-date results are then - immediately available and computationally free for reads within the - cluster. - - General guidelines - - This quickstart created an index on a view to maintain in-memory - up-to-date results in the cluster. In Materialize, both materialized - views and indexes on views incrementally update view results. - Materialized views persist the query results in durable storage and - is available across clusters while indexes maintain the view results - in memory within a single cluster. Some general guidelines for using - indexed views (I) vs. materialized views (M) include: - - - {/** - * Avoid wrapping in TableContainer, which uses immutable overflowY value of hidden. - */} - - - - - - - - - - - - - - - - - - - - - - - - - -
Usage Pattern
View results are accessed from a single cluster onlyI
View results are accessed across clustersM
- Final consumer of the view is a{" "} - - sinks - {" "} - or a{" "} - - SUBSCRIBE - {" "} - operation - M
- Use of{" "} - - temporal filters - - M
- - - The quickstart used an index since: - - - The examples did not need to store the results in durable storage. - - - - All activities were limited to the single quickstart cluster. - - - Although used, SUBSCRIBE operations were for - illustrative/validation purposes and were not the final consumer - of the views. - - - - Considerations - - Before creating an index (which represent query results stored in - memory), consider its memory usage as well as its - - {" "} - compute cost{" "} - - implications. For best practices when creating indexes, see{" "} - - Index Best Practices - - . - - - - ), - }, - { - title: "What's next?", - render: ({ runCommand, title }) => ( - <> - - {title} - - To start developing with your own data, click{" "} - Connect data. - - - For help getting started with your data or other questions about - Materialize, click{" "} - Talk to us. - - - Additional resources - - - - - - Clusters - - - - - Indexes - {" "} - - - - Sources - - - - - Views - - - - - Idiomatic Materialize SQL Chart - - - - - Usage and Billing - - - - - - CREATE INDEX - - - - - - - CREATE SCHEMA - - - - - - - CREATE VIEW - - - - - - - SELECT - - - - - - - SUBSCRIBE - - - - - - ), - }, -]; - /** * Since many steps have the same title, this gets the xth step of a given title */ const getLocalTutorialStepByTitle = ( + stepsData: StepData[], globalTutorialStep: number, title: string, ): number => { @@ -1335,9 +70,11 @@ const getLocalTutorialStepByTitle = ( const Steps = forwardRef( ( { + stepsData, runCommand, onChangeStep, }: { + stepsData: StepData[]; runCommand: (command: string) => void; onChangeStep: (desired: number) => void; }, @@ -1353,7 +90,8 @@ const Steps = forwardRef( const atEnd = currentTutorialStep >= stepsData.length - 1; - const step = stepsData[currentTutorialStep]; + const safeStepIndex = Math.min(currentTutorialStep, stepsData.length - 1); + const step = stepsData[safeStepIndex]; return ( @@ -1458,10 +196,12 @@ type TutorialProps = GridItemProps & { const Tutorial = ({ runCommand, ...rest }: TutorialProps) => { const { colors } = useTheme(); const [shellState, setShellState] = useAtom(shellStateAtom); - const { currentTutorialStep: step } = shellState; + const { currentTutorialStep: step, activeTutorial } = shellState; const { track } = useSegment(); const stepsScrollContainerRef = useRef(null); + const stepsData = TUTORIALS[activeTutorial]; + const changeStep = (desired: number) => { if (desired < 0) { desired = 0; @@ -1477,16 +217,18 @@ const Tutorial = ({ runCommand, ...rest }: TutorialProps) => { track("Tutorial change page", { pageHeader: title, pageNumber: desired, - sectionPageNumber: getLocalTutorialStepByTitle(desired, title), + sectionPageNumber: getLocalTutorialStepByTitle(stepsData, desired, title), quickstartVersion: QUICKSTART_VERSION, + tutorial: activeTutorial, }); const isChangingToSecondStep = prevStep === 0 && desired === 1; if (isChangingToSecondStep) { // If a user goes from the first page to the next page, we use this as a - // signal that the user has intended to start the quickstart + // signal that the user has intended to start the tutorial. track("Quickstart Start", { quickstartVersion: QUICKSTART_VERSION, + tutorial: activeTutorial, }); } @@ -1494,9 +236,10 @@ const Tutorial = ({ runCommand, ...rest }: TutorialProps) => { prevStep === stepsData.length - 2 && desired === stepsData.length - 1; if (isChangingToLastStep) { // If a user goes from the second last page to the last page, we use this - // as a signal that the user has completed the quickstart. + // as a signal that the user has completed the tutorial. track("Quickstart End", { quickstartVersion: QUICKSTART_VERSION, + tutorial: activeTutorial, }); } @@ -1535,10 +278,11 @@ const Tutorial = ({ runCommand, ...rest }: TutorialProps) => { color={colors.foreground.secondary} marginBottom="4" > - QUICKSTART + {TUTORIAL_SHORT_LABELS[activeTutorial].toUpperCase()}
( + <> + + {title} + + This tutorial is an in-console rendition of the MZ Academy + intro-to-Materialize course. It walks through views, materialized + views, indexes, idiomatic Materialize SQL, and temporal filters + against a small e-commerce dataset replicated from PostgreSQL plus a + Kafka clickstream. + + Before you start + + The tutorial assumes you have a Materialize emulator with a + PostgreSQL source feeding an e-commerce schema ( + users,{" "} + products,{" "} + orders,{" "} + order_items,{" "} + shopping_carts,{" "} + shopping_cart_items,{" "} + categories,{" "} + featured_products), a Kafka + source feeding a clickstream{" "} + topic, and three user clusters:{" "} + source_cluster,{" "} + transform_cluster, and{" "} + serving_cluster. + + + If you're working from a stock emulator, the next step will + walk you through verifying which pieces are present and which still + need to be set up. + + + + ), + }, + { + title: "Verify the environment.", + render: ({ runCommand, title }) => ( + <> + + {title} + + Run the queries below. If any return an empty result set or an + error, complete the environment setup and come back to this step. + + + + + Confirm the three user clusters are present. + + + + You should see source_cluster,{" "} + transform_cluster, and{" "} + serving_cluster alongside the + built-in system clusters. + + + Confirm the PostgreSQL and Kafka sources are running. + + + + Expected: commerce_pg_source,{" "} + marketing_pg_source, and{" "} + clickstream_source. + + + Confirm the source tables and the clickstream parsing view exist. + + + + + Expect tables users,{" "} + products,{" "} + orders,{" "} + order_items,{" "} + shopping_carts,{" "} + shopping_cart_items,{" "} + categories,{" "} + featured_products, and a{" "} + clickstream view that parses + the Kafka topic. + + + + ), + }, + { + title: "Clusters: compute resources, isolated.", + render: ({ runCommand, title }) => ( + <> + + {title} + + A{" "} + + cluster + {" "} + in Materialize provides the CPU and memory needed by sources, + indexes, materialized views, and ad-hoc queries. Clusters are + isolated from each other — memory in one cluster (e.g., an index) is + not visible to another. For production deployments we recommend + separating workloads into three clusters: + + + + source_cluster for source + ingestion. + + + transform_cluster for + materialized views and shared transform indexes. + + + serving_cluster for + query-time indexes and end-user reads. + + + + Tables, views, and materialized views are reachable from any + cluster. Indexes are not — they live in one cluster's memory + and are only used by queries that run on that cluster. + + + + + Switch to transform_cluster for + the upcoming view and materialized-view exercises. + + + + + + + ), + }, + { + title: "Views: define-once, recomputed-on-query.", + render: ({ runCommand, title }) => ( + <> + + {title} + + A{" "} + + view + {" "} + is a query definition saved under a name. Unindexed regular views + don't store results — each query against the view recomputes + them from scratch. They're great for readability and + composition; pick a materialized view or an index later only when + usage patterns justify the storage or memory. + + + In this step we stack four shopping-cart views on top of the + replicated shopping_carts,{" "} + shopping_cart_items, and{" "} + products tables. + + + + + Per-line-item subtotals, with an out-of-stock alert. + + + + + + Per-cart total, built on top of the previous view. + + + + + + Checkout view that joins line items with their cart totals. + + + + + + + Query the views from a different cluster — tables and views work + cross-cluster. + + + + + The EXPLAIN plan lists the base + tables and the join/aggregate operations that must run on every + query — there's no precomputed state yet. + + + + ), + }, + { + title: "Materialized views: incremental, persisted, cross-cluster.", + render: ({ runCommand, title }) => ( + <> + + {title} + + A{" "} + + materialized view + {" "} + persists its result set in durable storage and{" "} + incrementally updates it as the input tables + change. Queries against the materialized view read the already-up- + to-date results — no recomputation needed. The persisted state is + visible to every cluster. + + + Tip: when stacking views, you typically only materialize the + top-level one. Leave the underlying views as regular views unless + you have other consumers. + + + + + Build a per-product sales view, then a materialized view on top of + it that includes every product (including ones with no sales yet). + + + + + + + Read it from serving_cluster{" "} + and compare EXPLAIN output to + the regular-view plan. + + + + + + The materialized view's plan reads from the stored result — no + join, no aggregate. The plain view's plan still has the join + + aggregate operations. + + + + ), + }, + { + title: "Indexes: in-memory, cluster-local.", + render: ({ runCommand, title }) => ( + <> + + {title} + + An{" "} + + index + {" "} + in Materialize keeps a view's (or materialized view's) + full result set in a specific cluster's memory, organized by a + hashed key, and updates it incrementally. Two properties to + internalize: + + + + Cluster-local. Only queries running on the + index's cluster can use it. + + + Hashed key. Equality on the full key is a point + lookup. Anything else (prefix, range, OR with non-key clauses) + still runs against the index but does a full scan — fast, but not + as fast. + + + + + + Index the product_sales view + and the product_performance{" "} + materialized view in{" "} + serving_cluster. + + + + + + + Verify the cluster-local property: the same query plan from + transform_cluster does{" "} + not use the index. + + + + + + + Point lookup vs full scan. The first query specifies an equality on + the full key; the second only matches the prefix. + + + + + + + + ), + }, + { + title: "Index reuse and creation order.", + render: ({ runCommand, title }) => ( + <> + + {title} + + When you create an index or a materialized view, Materialize binds + the plan once and looks for an existing index in the{" "} + same cluster to reuse. Ad-hoc queries are + re-planned at query time and can use any index that exists when they + run. The upshot: creation order matters for indexes + and materialized views. An existing materialized view will not + retroactively pick up an index created after it. + + + + + Index product_sales in{" "} + transform_cluster, then add a + second materialized view that will use it. + + + + + + + Check what depends on the new index. Only{" "} + category_sales_summary (created + after the index) uses it.{" "} + product_performance, which was + created earlier, still reads the base tables. + + + + + + Recreate product_performance so + it picks up the index. The{" "} + CASCADE is needed because the + earlier product_performance_idx{" "} + depends on it; we'll add it back afterwards. + + + + + + + + Verify product_performance now + depends on the index instead of{" "} + order_items. + + + + + + + ), + }, + { + title: "Idiomatic SQL: top-K with LATERAL + LIMIT.", + render: ({ runCommand, title }) => ( + <> + + {title} + + When you maintain a view that uses a window function (e.g.,{" "} + RANK() OVER (PARTITION BY …)), + any change to a record in a partition forces Materialize to + recompute that whole partition from scratch. For Top-K-per-group + workloads, express the intent as a{" "} + + LATERAL subquery with{" "} + ORDER BY ... LIMIT + {" "} + and let Materialize compile to an incremental TopK operator. + + + + + Build a reusable per-product revenue view, then a top-3-per-category + view via LATERAL. + + + + + + + Inspect the plan — look for{" "} + Monotonic TopK, not{" "} + Non-incremental GroupAggregate. + + + + + + For K=1, DISTINCT ON is the + equivalent idiom and compiles to a{" "} + Monotonic Top1. + + + + + + + + ), + }, + { + title: "Idiomatic SQL: rewrite aggregate-as-window.", + render: ({ runCommand, title }) => ( + <> + + {title} + + COUNT(*) OVER (PARTITION BY …){" "} + and friends are window functions even when the aggregate isn't + obvious. In a maintained view, any insert/update/delete forces a + full per-partition recompute. Rewrite as a separate{" "} + GROUP BY view (or CTE) and + join. + + + + + Per-order line-item count, then joined back into{" "} + order_items. + + + + + + The plan shows{" "} + Accumulable GroupAggregate{" "} + (incremental) instead of{" "} + Non-incremental GroupAggregate. + + + + ), + }, + { + title: "Temporal filters with mz_now().", + render: ({ runCommand, title }) => ( + <> + + {title} + + A{" "} + + temporal filter + {" "} + uses mz_now() in a{" "} + WHERE clause to age records out + of a sliding window. Two constraints to remember when using + mz_now() in a materialized + view, indexed view, or{" "} + SUBSCRIBE: + + + + Comparison only.{" "} + mz_now() only participates in + comparison operators. Move any arithmetic to the other side of the + comparison. + + + AND only. Top-level{" "} + WHERE conditions including{" "} + mz_now() must be{" "} + AND-combined. Rewrite{" "} + OR with{" "} + UNION. + + + + + + Per-user spending in the last 1 minute, plus a top-100 view on top. + The artificial 1-minute window lets you watch records enter and + leave the result set live. + + mz_now() +GROUP BY o.user_id, u.name, u.email;`} + title="user_spending_1min — uses a temporal filter" + /> + + + + To exit the subscribe, click Stop streaming. + + + Combine the top-K idiom with a temporal filter: top-5 most-viewed + products per category in the last 24 hours, from the{" "} + clickstream view. + + mz_now() +GROUP BY p.id, p.name, p.category_id;`} + title="product_view_counts_24h" + /> + + + + + + ), + }, + { + title: "Filter pushdown: when to materialize first.", + render: ({ runCommand, title }) => ( + <> + + {title} + + Two patterns for combining a temporal filter with a join: + + + + Filter-then-materialize. Filter is part of the + materialized view; the storage layer pushes the temporal predicate + down so only currently-active rows enter the join. Bounded by the + active set. Good when records activate at a relatively steady + rate. + + + Materialize-then-filter. First MV joins + everything without the temporal filter; a second MV applies the + filter on top. Steady-state memory holds all rows; activation + bursts (e.g. a big promotion going live) reveal already-joined + rows instead of triggering a join surge. + + + + + + Filter-then-materialize: a single MV with the temporal predicate + inline. + + = f.start_date + AND mz_now() < f.end_date;`} + title="Filter-then-materialize MV" + /> + + + + + Materialize-then-filter: two stacked MVs. + + + = start_date + AND mz_now() < end_date;`} + title="Step 2: apply the temporal filter on top" + /> + + + Memory profile: filter-then-materialize ≈ active set. + Materialize-then-filter ≈ all rows + active set. Trade memory for + burst-resilience. + + + + ), + }, + { + title: "MCP server for developer analysis.", + render: ({ title }) => ( + <> + + {title} + + The Materialize emulator exposes a built-in{" "} + + Model Context Protocol + {" "} + server at{" "} + + http://localhost:6876/api/mcp/developer + + . Connect an MCP-compatible client (Claude Code, Claude Desktop, + Cursor, etc.) and ask introspection questions like "what + sources are running and have they snapshotted?", "is any + cluster running out of memory?", or "suggest + optimizations". + + + Materialize publishes two complementary agent skills on GitHub —{" "} + mcp-developer-analysis (for + introspection workflows) and{" "} + materialize-docs (for syntax + and concepts). Both are installable via{" "} + + npx skills add MaterializeInc/agent-skills + + . + + + Setup steps run in your terminal (see the{" "} + + MCP Developer + {" "} + docs), but the in-console shortcut is to create a narrowly-scoped + login role for the agent: + + + + CREATE ROLE my_dev_agent; + + + + The role inherits the default{" "} + PUBLIC privileges, which let it + read the system catalog through the MCP server's + mz_catalog_server system + cluster. From your terminal, point your MCP client at{" "} + + http://localhost:6876/api/mcp/developer + {" "} + with Basic auth header{" "} + my_dev_agent: (no password) and + you're wired up. + + + + ), + }, + { + title: "Summary and what to explore next.", + render: ({ title }) => ( + <> + + {title} + In this tutorial you covered: + + + The three-cluster architecture (source / transform / serving) and + cluster isolation. + + + Views (re-computed per query), materialized views (persisted, + incremental, cross-cluster), and indexes (in-memory, + cluster-local, hashed-key). + + + Index reuse and why creation order matters for maintained objects. + + + Idiomatic Materialize SQL for top-K ( + LATERAL +{" "} + LIMIT,{" "} + DISTINCT ON) and aggregate + rewrites. + + + Temporal filters with{" "} + mz_now() and the + filter-pushdown / pre-materialize trade-off. + + + The built-in MCP server for connecting AI agents to your + environment's system catalog. + + + Where to go next + + + + Clusters concept docs + + + + + Indexes concept docs + + + + + Idiomatic Materialize SQL chart + + + + + Temporal-filter patterns + + + + + + ), + }, +]; diff --git a/console/src/platform/shell/quickstartSteps.tsx b/console/src/platform/shell/quickstartSteps.tsx new file mode 100644 index 0000000000000..1a9b1683b26cf --- /dev/null +++ b/console/src/platform/shell/quickstartSteps.tsx @@ -0,0 +1,1207 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { + Code, + ListItem, + OrderedList, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + UnorderedList, +} from "@chakra-ui/react"; +import React from "react"; + +import TextLink from "~/components/TextLink"; +import docUrls from "~/mz-doc-urls.json"; + +import TutorialInsertionWidget from "./TutorialInsertionWidget"; +import TutorialSchemaWidget from "./TutorialSchemaWidget"; +import { + Box, + Runnable, + RunnableContainer, + StepData, + TextContainer, +} from "./tutorialUtils"; + +export const quickstartStepsData: StepData[] = [ + { + title: "Introduction", + render: ({ title }) => ( + <> + + {title} + + Materialize provides always-fresh results while also providing + strong consistency guarantees. In Materialize, both{" "} + + indexes on views + {" "} + and{" "} + + materialized views + {" "} + incrementally update results when Materialize ingests new data, + meaning that work is performed on writes. + + In the quickstart, you will: + + + Create and query various views on sample auction data. The data is + continually generated at 1 second intervals to mimic a + data-intensive workload. + + + Create an index on a view to compute and store view results in + memory. As new auction data arrives, the index incrementally + updates view results. + + + Create and query views to verify that Materialize always serves + consistent results. + + + + + ), + }, + { + title: "Create a schema.", + render: ({ title }) => ( + <> + + {title} + + By default, you are working in the{" "} + materialize.public{" "} + + namespace + {" "} + where: + + + + materialize is the database + name and + + + public is the schema name. + + + + + + + Create a separate schema for this quickstart. For a schema name to + be valid: + + + + The first character must be either: an ASCII letter (a-z and + A-Z), an underscore (_), or a non-ASCII character. + + + The remaining characters can be: an ASCII letter (a-z and A-Z), + ASCII numbers (0-9), an underscore (_), dollar signs ($), or a + non-ASCII character. + + + + Alternatively, by double-quoting the name, you can bypass the + aforementioned constraints with the following exception: schema + names, whether double-quoted or not, cannot contain the dot ( + .) See also{" "} + + Identifier: Naming restrictions + {" "} + + + + + + + + Switch to the new schema. From the top of the SQL Shell, select your + schema from the namespace dropdown. + + + + ), + }, + { + title: "Create the source.", + render: ({ runCommand, title }) => ( + <> + + {title} + + + Sources + {" "} + are external systems from which Materialize reads in data. This + tutorial uses Materialize's sample{" "} + + Auction load generator + {" "} + to create the sources. + + + + + + Create the source using the{" "} + + + CREATE SOURCE + + {" "} + command. For the sample load generator, the quickstart uses{" "} + + + CREATE SOURCE + + {" "} + with the FROM LOAD GENERATOR{" "} + clause that works specifically with Materialize's sample data + generators. The tutorial specifies that the generator should emit + new data every 1s. + + + + + + CREATE SOURCE + + {" "} + can create multiple tables (referred to as + subsources in Materialize) when + ingesting data from multiple upstream tables. For each upstream + table that is selected for ingestion, Materialize creates a + subsource. + + + Use{" "} + + + SHOW SOURCES + + {" "} + command to see the results of the previous step. + + + + A subsource is how Materialize refers to a table that has the + following properties: + + + A subsource can only be written by the source; in this case, the + load-generator. + + Users can read from subsources. + + + + + Use the{" "} + + + SELECT + + {" "} + statement to query auctions and{" "} + bids. + + + + + + + + Subsequent steps in this quickstart uses a query to find winning + bids for auctions to show how Materialize uses views and indexes + to provide immediately available up-to-date results for various + queries. + + + + + + ), + }, + { + title: "Create a view to find winning bids.", + render: ({ runCommand, title }) => ( + <> + + {title} + + A{" "} + + view + {" "} + is a saved name for the underlying{" "} + + + SELECT{" "} + + {" "} + statement, providing an alias/shorthand when referencing the query. + The underlying query is not executed during the view creation; + instead, the underlying query is executed when the view is + referenced. + + + Assume you want to find the winning bids for auctions that have + ended. The winning bid for an auction is the highest bid entered for + an auction before the auction ended. As new auction and bid data + appears, the query must be rerun to get up-to-date results. + + + + + + Use{" "} + + + CREATE VIEW + + {" "} + to create a view for the winning bids. Materialize provides an + idiomatic way to perform{" "} + + Top-K queries + + . For K = 1, the idiomatic Materialize SQL uses + + {" "} + + DISTINCT ON (...) + + {" "} + to return only the first row for each distinct auction id. + + = a.end_time + ORDER BY a.id, + b.amount DESC, + b.bid_time, + b.buyer;`} + title="Create a view." + /> + + + + + + + SELECT + + {" "} + from the view to execute the underlying query. For example: + + + + + + + Since new data is continually being ingested, you must{" "} + rerun the query to + get the up-to-date results. Each time you query the view, you + are re-running the underlying statement, which becomes less + performant as the amount of data grows. + + + In Materialize, to make the queries more performant even as data + continues to grow, you can create{" "} + + indexes + {" "} + on views. Indexes provide always fresh view results in memory + within a cluster by performing{" "} + + incremental updates as new data arrives + + . Queries can then read from the in-memory, already up-to-date + results instead of re-running the underlying statement, making + queries{" "} + + computationally free and more performant + + . + + + In the next step, you will create an index on{" "} + winning_bids. + + + + + + ), + }, + { + title: "Create an index to provide up-to-date results.", + render: ({ runCommand, title }) => ( + <> + + {title} + + Indexes in Materialize represent query results stored in memory + within a cluster. In Materialize, you can create{" "} + + indexes + {" "} + on views to provide always fresh view results in memory within a + cluster. Queries can then read from the in-memory, already + up-to-date results instead of re-running the underlying statement. + + + To provide the up-to-date results, indexes + + {" "} + perform incremental updates{" "} + {" "} + as inputs change instead of recalculating the results from scratch. + Additionally, indexes can also help{" "} + + optimize operations + {" "} + like point lookups and joins. + + + + + + Use the{" "} + + + CREATE INDEX + + {" "} + command to create the following index on the + winning_bids view. + + + + + + During the index creation, the underlying{" "} + winning_bids query is + executed, and the view results are stored in memory within the + cluster.{" "} + + As new data arrives, the index incrementally updates the view + results in memory.{" "} + {" "} + Because incremental work is performed on writes, reads from + indexes return up-to-date results and are computationally free. + + + The index can also help{" "} + + optimize + {" "} + operations like point lookups and{" "} + + delta joins{" "} + + on the index column(s) as well as support ad-hoc queries. + + + + + Rerun the previous queries on{" "} + winning_bids. + + + + + + The queries should be faster since they use the in-memory, already + up-to-date results computed by the index. + + + + + ), + }, + { + title: "Create views and a table to find flippers in real time.", + render: ({ runCommand, title }) => ( + <> + + {title} + + For this quickstart, auction flipping activities are defined as when + a user buys an item in one auction and resells the same item at a + higher price within an 8-day period. This step finds auction + flippers in real time, based on auction flipping activity data and + known flippers data. Specifically, this step creates: + + + + + A view to find auction flipping activities. Results are updated as + new data comes in (at 1 second intervals) from the data generator. + + + A table that maintains known auction flippers. You will manually + enter new data to this table. + + + A view to immediately see auction flippers based on either the + flipping activities view or the known auction flippers table. + + + + + + + Create a view to detect auction flipping activities. + + w1.amount + AND datediff('days',w2.bid_time,w1.bid_time) < 8 +;`} + title="Create a view flip_activities" + /> + + + The flip_activities view + can use the index created on + winning_bids view to + provide up-to-date data. + + + To view a sample row in{" "} + flip_activities, run the + following{" "} + + + SELECT{" "} + + {" "} + command: + + + + + + Use{" "} + + + CREATE TABLE{" "} + + {" "} + to create a known_flippers{" "} + table that you can manually populate with known flippers. That is, + assume that separate from your auction activities data, you receive + independent data specifying users as flippers. + + + + + + Create a view flippers to + immediately see flippers if either: + + A user has 2 or more flipping activities; or + + A user is listed in the + known_flippers table. + + + + = 2 + + UNION ALL + + SELECT flipper_id + FROM known_flippers +);`} + title="Create a view flippers" + /> + + + + + Both the known_flippers and{" "} + flippers views can use the index + created on winning_bids view to + provide up-to-date data. Depending upon your query patterns and usage, + an existing index may be sufficient, such as in this quickstart. In + other use cases, creating an index only on the view(s) from which you + will serve results may be preferred. + + + ), + }, + { + title: "Subscribe to see results change.", + render: ({ runCommand, title }) => ( + <> + + {title} + + + + SUBSCRIBE{" "} + + {" "} + to flippers to see new flippers + appear as new data arrives (either from the{" "} + known_flippers table or the{" "} + flip_activities view). + + + + + + Use{" "} + + + SUBSCRIBE + + {" "} + to see flippers as new data arrives (either from the{" "} + known_flippers table or the{" "} + flip_activities view). + + + + The optional{" "} + WITH (snapshot = false){" "} + indicates that the command displays only the new flippers that come + in after the start of the{" "} + + + SUBSCRIBE + + {" "} + operation, and not the flippers in the view at the start of the + operation. + + + + Enter a flipper id into the{" "} + known_flippers table. You can + specify any number for the flipper id. + + + + + + The flipper should immediately appear in the{" "} + + + SUBSCRIBE + + {" "} + results. You should also see flippers who are flagged by their flip + activities. Because of the randomness of the auction data being + generated, user activity data that match the definition of a flipper + may take some time even though auction data is constantly being + ingested. However, once new matching data comes in, you will see it + immediately in the{" "} + + + SUBSCRIBE + + {" "} + results. While waiting, you can enter additional flippers into the{" "} + known_flippers table. + + + To cancel out of the + + + SUBSCRIBE{" "} + + + , click the{" "} + Stop streaming button. + + + + ), + }, + { + title: "Verify that Materialize returns consistent data.", + render: ({ runCommand, title }) => ( + <> + + {title} + + + To verify that Materialize serves consistent results, even as new + data comes in, this step creates the following views for completed + auctions: + + + + + A view to keep track of each seller's credits. + + + A view to keep track of each buyer's debits. + + + A view that sums all sellers' credits, all buyers' + debits, and calculates the difference, which should be 0. + + + + + + + Create a view to track credited amounts for sellers of completed + auctions. + + + + + + Create a view to track debited amounts for the winning bidders of + completed auctions. + + + + + + To verify that the total credit and total debit amounts equal for + completed auctions (i.e., to verify that the results are correct and + consistent even as new data comes in), create a{" "} + funds_movement view that + calculates the total credits across sellers, total debits across + buyers, and the difference between the two. + + + + + + To see that the sums always equal even as new data comes in, you can{" "} + + + SUBSCRIBE + + {" "} + to this query: + + + + Toggle Show diffs to see + changes to funds_movement. As + new data comes in and auctions complete, the{" "} + total_credits and + total_debits values should + change but the total_difference + should remain 0. + + + To cancel out of the{" "} + + + SUBSCRIBE + + + , click the{" "} + Stop streaming button. + + + + ), + }, + { + title: "Clean up.", + render: ({ runCommand, title }) => ( + <> + + {title} + + To clean up the quickstart environment: + + + + + Use{" "} + + + DROP SOURCE + + {" "} + with the CASCADE option to + drop auction_house and its + dependent objects (e.g., views and indexes). + + + + Use{" "} + + {" "} + + DROP TABLE + + {" "} + to drop the known_flippers{" "} + table + + + + + + + + ), + }, + { + title: "Summary", + render: ({ title }) => ( + <> + + {title} + + In Materialize,{" "} + + indexes + {" "} + represent query results stored in memory within a cluster. When you + create an index on a view, the index incrementally updates the view + results (instead of recalculating the results from scratch) as + Materialize ingests new data. These up-to-date results are then + immediately available and computationally free for reads within the + cluster. + + General guidelines + + This quickstart created an index on a view to maintain in-memory + up-to-date results in the cluster. In Materialize, both materialized + views and indexes on views incrementally update view results. + Materialized views persist the query results in durable storage and + is available across clusters while indexes maintain the view results + in memory within a single cluster. Some general guidelines for using + indexed views (I) vs. materialized views (M) include: + + + {/** + * Avoid wrapping in TableContainer, which uses immutable overflowY value of hidden. + */} + + + + + + + + + + + + + + + + + + + + + + + + + +
Usage Pattern
View results are accessed from a single cluster onlyI
View results are accessed across clustersM
+ Final consumer of the view is a{" "} + + sinks + {" "} + or a{" "} + + SUBSCRIBE + {" "} + operation + M
+ Use of{" "} + + temporal filters + + M
+ + + The quickstart used an index since: + + + The examples did not need to store the results in durable storage. + + + + All activities were limited to the single quickstart cluster. + + + Although used, SUBSCRIBE operations were for + illustrative/validation purposes and were not the final consumer + of the views. + + + + Considerations + + Before creating an index (which represent query results stored in + memory), consider its memory usage as well as its + + {" "} + compute cost{" "} + + implications. For best practices when creating indexes, see{" "} + + Index Best Practices + + . + + + + ), + }, + { + title: "What's next?", + render: ({ title }) => ( + <> + + {title} + + To start developing with your own data, click{" "} + Connect data. + + + For help getting started with your data or other questions about + Materialize, click{" "} + Talk to us. + + + Additional resources + + + + + + Clusters + + + + + Indexes + {" "} + + + + Sources + + + + + Views + + + + + Idiomatic Materialize SQL Chart + + + + + Usage and Billing + + + + + + CREATE INDEX + + + + + + + CREATE SCHEMA + + + + + + + CREATE VIEW + + + + + + + SELECT + + + + + + + SUBSCRIBE + + + + + + ), + }, +]; diff --git a/console/src/platform/shell/store/shell.ts b/console/src/platform/shell/store/shell.ts index 8c5d4070761de..785205ceaa81a 100644 --- a/console/src/platform/shell/store/shell.ts +++ b/console/src/platform/shell/store/shell.ts @@ -24,10 +24,12 @@ import { clearListItemHeights } from "../heightByListItem"; import { createHistoryId, HistoryId } from "../historyId"; import { WebSocketFsmState } from "../machines/webSocketFsm"; import { PlanInsights } from "../plan-insights/PlanInsightsNotice"; +import { TUTORIAL_IDS, TutorialId } from "./tutorialIds"; export type { HistoryId } from "../historyId"; export const SHELL_SIDEBAR_VISIBLE = "mz-shell-sidebar-visible"; +export const SHELL_ACTIVE_TUTORIAL = "mz-shell-active-tutorial"; export type SessionParameters = Partial<{ cluster: string; @@ -37,6 +39,7 @@ export type SessionParameters = Partial<{ type ShellState = { tutorialVisible: boolean; + activeTutorial: TutorialId; crtEnabled: boolean; webSocketState: WebSocketFsmState["value"] | null; sessionParameters: SessionParameters; @@ -50,6 +53,7 @@ type ShellState = { const initialShellState = { tutorialVisible: getStoredSidebarVisibility(), + activeTutorial: getStoredActiveTutorial(), crtEnabled: false, webSocketState: null, sessionParameters: {}, @@ -186,6 +190,22 @@ export function setStoredSidebarVisibility(visible: boolean) { window.localStorage.setItem(SHELL_SIDEBAR_VISIBLE, JSON.stringify(visible)); } } + +function getStoredActiveTutorial(): TutorialId { + if (storageAvailable("localStorage")) { + const stored = window.localStorage.getItem(SHELL_ACTIVE_TUTORIAL); + if (stored && (TUTORIAL_IDS as readonly string[]).includes(stored)) { + return stored as TutorialId; + } + } + return "quickstart"; +} + +export function setStoredActiveTutorial(tutorial: TutorialId) { + if (storageAvailable("localStorage")) { + window.localStorage.setItem(SHELL_ACTIVE_TUTORIAL, tutorial); + } +} /** * * A SUBSCRIBE command's output consists of an array of row where each diff --git a/console/src/platform/shell/store/tutorialIds.ts b/console/src/platform/shell/store/tutorialIds.ts new file mode 100644 index 0000000000000..1f084fa3d436a --- /dev/null +++ b/console/src/platform/shell/store/tutorialIds.ts @@ -0,0 +1,21 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +export const TUTORIAL_IDS = ["quickstart", "academy"] as const; +export type TutorialId = (typeof TUTORIAL_IDS)[number]; + +export const TUTORIAL_LABELS: Record = { + quickstart: "Quickstart", + academy: "MZ Academy: intro to Materialize", +}; + +export const TUTORIAL_SHORT_LABELS: Record = { + quickstart: "Quickstart", + academy: "MZ Academy", +}; diff --git a/console/src/platform/shell/tutorialUtils.tsx b/console/src/platform/shell/tutorialUtils.tsx new file mode 100644 index 0000000000000..d0728754236c7 --- /dev/null +++ b/console/src/platform/shell/tutorialUtils.tsx @@ -0,0 +1,114 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { + Box, + BoxProps, + ChakraTheme, + IconButton, + useTheme, + VStack, +} from "@chakra-ui/react"; +import { useAtomCallback } from "jotai/utils"; +import React, { forwardRef, PropsWithChildren } from "react"; + +import { TabbedCodeBlock } from "~/components/copyableComponents"; +import CommandIcon from "~/svg/CommandIcon"; +import { MaterializeTheme, ThemeColors } from "~/theme"; +import ColorsType from "~/theme/colors"; + +import { saveClearPrompt, setPromptValue } from "./store/prompt"; + +export type RunnableProps = { + runCommand: (value: string) => void; + title: string; + value: string; +}; + +export const Runnable = ({ runCommand, value, title }: RunnableProps) => { + const { colors } = useTheme(); + const setPrompt = useAtomCallback(setPromptValue); + const clearPrompt = useAtomCallback(saveClearPrompt); + + return ( + { + setPrompt(value); + runCommand(value); + clearPrompt(); + }} + icon={} + variant="unstyled" + rounded={0} + sx={{ + _hover: { + background: "rgba(255, 255, 255, 0.06)", + }, + }} + /> + } + /> + ); +}; + +export const TextContainer = ({ children }: PropsWithChildren) => ( + + {children} + +); + +export const RunnableContainer = ({ + children, + ...rest +}: PropsWithChildren & BoxProps) => ( + + {children} + +); + +export const StepLayout = forwardRef( + ({ children, ...rest }: PropsWithChildren & BoxProps, ref) => ( + + {children} + + ), +); + +export type StepRenderProps = { + runCommand: (value: string) => void; + title: string; + colors: ChakraTheme["colors"] & ThemeColors & typeof ColorsType; +}; + +export type StepData = { + title: string; + render: (props: StepRenderProps) => JSX.Element; +}; + +// Re-export Box so step-data files don't have to import from chakra directly +// for the common case of wrapping a widget in a Box. +export { Box }; From ccac732c2ba3952e65b1badaf73b0f88a4608e9f Mon Sep 17 00:00:00 2001 From: Jon Currey Date: Mon, 11 May 2026 13:37:55 -0400 Subject: [PATCH 3/3] console: tests for the MZ Academy tutorial picker - shell.test.ts: covers setStoredActiveTutorial / SHELL_ACTIVE_TUTORIAL localStorage round-trip. - Tutorial.test.tsx: renders the Tutorial component with each activeTutorial value and asserts the right header label and intro content appears. --- console/src/platform/shell/Tutorial.test.tsx | 52 +++++++++++++++++++ .../src/platform/shell/store/shell.test.ts | 26 +++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 console/src/platform/shell/Tutorial.test.tsx diff --git a/console/src/platform/shell/Tutorial.test.tsx b/console/src/platform/shell/Tutorial.test.tsx new file mode 100644 index 0000000000000..527bc968867a8 --- /dev/null +++ b/console/src/platform/shell/Tutorial.test.tsx @@ -0,0 +1,52 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import { createProviderWrapper } from "~/test/utils"; + +import { shellStateAtom } from "./store/shell"; +import Tutorial from "./Tutorial"; + +const renderTutorial = async (activeTutorial: "quickstart" | "academy") => { + const Wrapper = await createProviderWrapper({ + initializeState: async (store) => { + store.set(shellStateAtom, (prev) => ({ + ...prev, + activeTutorial, + currentTutorialStep: 0, + tutorialVisible: true, + })); + }, + }); + return render( + + undefined} /> + , + ); +}; + +describe("Tutorial", () => { + it("renders Quickstart content when activeTutorial=quickstart", async () => { + await renderTutorial("quickstart"); + // The Quickstart intro page mentions "auction data" prominently and the + // section label is "QUICKSTART". + expect(await screen.findByText("QUICKSTART")).toBeInTheDocument(); + }); + + it("renders MZ Academy content when activeTutorial=academy", async () => { + await renderTutorial("academy"); + expect(await screen.findByText("MZ ACADEMY")).toBeInTheDocument(); + // The first academy step's heading is "Welcome to MZ Academy". + expect( + await screen.findByText("Welcome to MZ Academy"), + ).toBeInTheDocument(); + }); +}); diff --git a/console/src/platform/shell/store/shell.test.ts b/console/src/platform/shell/store/shell.test.ts index 9c06483fdaced..3188f3bb649d4 100644 --- a/console/src/platform/shell/store/shell.test.ts +++ b/console/src/platform/shell/store/shell.test.ts @@ -10,9 +10,33 @@ import { SUBSCRIBE_METADATA_COLUMNS } from "~/api/materialize/SubscribeManager"; import { Column } from "~/api/materialize/types"; -import { mergeMzDiffs } from "./shell"; +import { + mergeMzDiffs, + setStoredActiveTutorial, + SHELL_ACTIVE_TUTORIAL, +} from "./shell"; describe("shell", () => { + describe("setStoredActiveTutorial", () => { + afterEach(() => { + window.localStorage.removeItem(SHELL_ACTIVE_TUTORIAL); + }); + + it("persists an academy selection", () => { + setStoredActiveTutorial("academy"); + expect(window.localStorage.getItem(SHELL_ACTIVE_TUTORIAL)).toBe( + "academy", + ); + }); + + it("persists a quickstart selection", () => { + setStoredActiveTutorial("quickstart"); + expect(window.localStorage.getItem(SHELL_ACTIVE_TUTORIAL)).toBe( + "quickstart", + ); + }); + }); + describe("mergeMzDiffs", () => { it("should return the input if isStreamingResult is false", () => { const commandResult = {