├── .gitignore
├── .dockerignore
├── web
├── src
│ ├── vite-env.d.ts
│ ├── types.tsx
│ ├── datetime.ts
│ ├── data
│ │ ├── timeseries.ts
│ │ ├── sql.ts
│ │ ├── constants.ts
│ │ ├── sqlgen.ts
│ │ ├── client.ts
│ │ ├── models
│ │ │ └── useUpdateCityList.ts
│ │ ├── recoil.ts
│ │ └── offers.ts
│ ├── index.d.ts
│ ├── components
│ │ ├── customcomponents
│ │ │ ├── loader
│ │ │ │ ├── centering-wrapper.scss
│ │ │ │ ├── CenteringWrapper.tsx
│ │ │ │ └── loader.scss
│ │ │ └── Button.tsx
│ │ ├── CodeBlock.tsx
│ │ ├── SetupDatabaseButton.tsx
│ │ ├── ConfigInput.tsx
│ │ ├── ScaleFactorSelector.tsx
│ │ ├── OfferMap.tsx
│ │ ├── Footer.tsx
│ │ ├── GithubButtons.tsx
│ │ ├── dataConfigForm
│ │ │ ├── DatabaseConfigFormAutomatic.tsx
│ │ │ └── DatabaseConfigFormManual.tsx
│ │ ├── IconLinks.tsx
│ │ ├── Stats.tsx
│ │ ├── theme.ts
│ │ ├── ErrorHandler.tsx
│ │ ├── ResetSchemaButton.tsx
│ │ ├── EnableSimulatorButton.tsx
│ │ ├── IngestChart.tsx
│ │ ├── HeatMap.tsx
│ │ ├── NeedHelpModal.tsx
│ │ └── navBar
│ │ │ └── Nav.tsx
│ ├── rand.ts
│ ├── format.ts
│ ├── events.ts
│ ├── analytics.ts
│ ├── view
│ │ └── hooks
│ │ │ ├── useSimulationMonitor.ts
│ │ │ ├── useSession.ts
│ │ │ └── useSimulator.ts
│ ├── assets
│ │ ├── singlestore-logo-dark.svg
│ │ ├── singlestore-logo-filled-sm.svg
│ │ └── singlestore-logo-holo.svg
│ ├── geo.ts
│ ├── scalefactors.ts
│ ├── main.tsx
│ ├── pages
│ │ └── HomePage.tsx
│ ├── render
│ │ └── useNotificationsRenderer.ts
│ └── App.tsx
├── .gitignore
├── .prettierrc
├── postcss.config.js
├── public
│ ├── socialPostLinkedIn.png
│ └── 404.html
├── tsconfig.json
├── vite.config.ts
├── package.json
├── index.html
└── .eslintrc.js
├── doc
└── architecture.png
├── .vscode
├── tasks.json
└── launch.json
├── output
├── encoder.go
├── extension.go
├── writer.go
├── writer_test.go
├── blob.go
├── mock_test.go
├── json.go
└── parquet.go
├── gen
├── state.go
├── subscribers.go
├── update.go
└── fill.go
├── Dockerfile
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── build-image.yml
│ └── on-push.yml
├── data
├── vendors_test.go
└── vendors.go
├── sql
├── pipelines.sql
├── seed.sql
├── procedures.sql
├── functions.sql
└── schema.sql
├── .devcontainer
├── Dockerfile
└── devcontainer.json
├── util
└── rand.go
├── go.mod
├── cmd
└── simulator
│ └── main.go
├── CODE_OF_CONDUCT.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | credentials.sql
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | web/node_modules
2 | web/dist
3 | Dockerfile
--------------------------------------------------------------------------------
/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | dist-ssr
5 | *.local
6 |
--------------------------------------------------------------------------------
/doc/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/singlestore-labs/demo-realtime-digital-marketing/main/doc/architecture.png
--------------------------------------------------------------------------------
/web/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | trailingComma: "es5",
3 | tabWidth: 2,
4 | semi: true,
5 | singleQuote: false
6 | }
7 |
--------------------------------------------------------------------------------
/web/postcss.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | module.exports = {
3 | plugins: {
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/web/src/types.tsx:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | analytics: SegmentAnalytics.AnalyticsJS;
4 | }
5 | }
6 |
7 | export {};
8 |
--------------------------------------------------------------------------------
/web/public/socialPostLinkedIn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/singlestore-labs/demo-realtime-digital-marketing/main/web/public/socialPostLinkedIn.png
--------------------------------------------------------------------------------
/web/src/datetime.ts:
--------------------------------------------------------------------------------
1 | export const toISOStringNoTZ = (date: Date) =>
2 | // SingleStore doesn't support timezones in date formats
3 | // so we strip the last character (Z)
4 | date.toISOString().slice(0, -1);
5 |
--------------------------------------------------------------------------------
/web/src/data/timeseries.ts:
--------------------------------------------------------------------------------
1 | export type TimeseriesPoint = [Date, number];
2 | export type Timeseries = Array;
3 |
4 | export const timeseriesIsEmpty = (series: Timeseries): boolean =>
5 | series.every(([, y]) => y === 0);
6 |
--------------------------------------------------------------------------------
/web/src/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.sql" {
2 | export type SchemaObject = {
3 | kind: string;
4 | name?: string;
5 | statement: string;
6 | };
7 |
8 | const src: Array;
9 | export default src;
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "frontend dev server",
6 | "type": "shell",
7 | "command": "cd web && yarn run dev",
8 | "problemMatcher": []
9 | }
10 | ]
11 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "type": "chrome",
6 | "request": "launch",
7 | "name": "Launch Chrome against localhost",
8 | "url": "http://localhost:3000",
9 | "webRoot": "${workspaceFolder}"
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/output/encoder.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "io"
5 | )
6 |
7 | type BatchEncoder interface {
8 | Extension() string
9 | EncodeLocations(int64, []Location, io.Writer) error
10 | EncodeRequests(int64, []Request, io.Writer) error
11 | EncodePurchases(int64, []Purchase, io.Writer) error
12 | }
13 |
--------------------------------------------------------------------------------
/gen/state.go:
--------------------------------------------------------------------------------
1 | package gen
2 |
3 | import (
4 | "subscriber-sim/util"
5 | )
6 |
7 | type State struct {
8 | PartitionId int
9 | SeqId int64
10 | Rand util.RandWithSource
11 | Subscribers []Subscriber
12 |
13 | PurchaseProb float64
14 | RequestProb float64
15 |
16 | MinSpeed float64
17 | MaxSpeed float64
18 | }
19 |
--------------------------------------------------------------------------------
/web/src/components/customcomponents/loader/centering-wrapper.scss:
--------------------------------------------------------------------------------
1 | .single-common-components-centering-wrapper {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | justify-content: center;
6 |
7 | width: 100%;
8 | overflow-y: auto;
9 | margin-top: auto;
10 | margin-bottom: auto;
11 |
12 | &.vertical {
13 | height: 100%;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-alpine as builder
2 | WORKDIR /usr/src/app
3 |
4 | COPY web/package.json web/yarn.lock ./web/
5 | RUN cd web && yarn install
6 |
7 | COPY . ./
8 | RUN cd web && yarn build
9 |
10 | RUN adduser -D static
11 |
12 | FROM joseluisq/static-web-server:2
13 |
14 | COPY --from=builder /etc/passwd /etc/passwd
15 | USER static
16 |
17 | COPY --from=builder /usr/src/app/web/dist/ /public/
18 |
19 | EXPOSE 3000
20 | ENV SERVER_FALLBACK_PAGE=/public/404.html
21 | ENV SERVER_PORT=3000
--------------------------------------------------------------------------------
/web/src/components/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import { Code, HTMLChakraProps, ThemingProps } from "@chakra-ui/react";
2 | import * as React from "react";
3 |
4 | export interface Props extends HTMLChakraProps<"code">, ThemingProps<"Code"> {}
5 |
6 | export const CodeBlock = ({ children, ...props }: Props) => (
7 |
17 | {children}
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/output/extension.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "fmt"
5 | "time"
6 | )
7 |
8 | type ExtensionGenerator func(Batch, BatchEncoder) string
9 |
10 | func NewExtensionGenerator() ExtensionGenerator {
11 | // seq ensures we don't generate two files at the exact same nanosecond
12 | seq := 0
13 |
14 | return func(batch Batch, enc BatchEncoder) string {
15 | seq++
16 | t := time.Now().UTC()
17 |
18 | return fmt.Sprintf(
19 | ".%04d-%02d-%02d.%02d%02d%02d.%09d.%04d.%s",
20 | t.Year(),
21 | t.Month(),
22 | t.Day(),
23 | t.Hour(),
24 | t.Minute(),
25 | t.Second(),
26 | t.Nanosecond()+seq,
27 | batch.PartitionId(),
28 | enc.Extension(),
29 | )
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/web/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Redirecting...
9 |
10 |
11 |
12 |
13 | Redirecting...
14 |
15 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: 'enhancement'
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "baseUrl": "./",
19 | "paths": {
20 | "@/*": ["./src/*"],
21 | "@/sql/*": ["../sql/*"],
22 | "@/static-data/*": ["../data/*"]
23 | }
24 | },
25 | "include": ["./src"]
26 | }
27 |
--------------------------------------------------------------------------------
/output/writer.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/ungerik/go3d/float64/vec2"
7 | )
8 |
9 | type Location struct {
10 | SubscriberId int64
11 | Offset vec2.T
12 | }
13 |
14 | type Request struct {
15 | SubscriberId int64
16 | Domain string
17 | }
18 |
19 | type Purchase struct {
20 | SubscriberId int64
21 | Vendor string
22 | }
23 |
24 | type Batch interface {
25 | PartitionId() int
26 | SeqId() int64
27 | Locations() []Location
28 | Requests() []Request
29 | Purchases() []Purchase
30 | }
31 |
32 | type Writer interface {
33 | // Write will encode and store the provided batch in a location.
34 | // The batch is guaranteed to not be reused after Write returns.
35 | Write(context.Context, Batch) error
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/build-image.yml:
--------------------------------------------------------------------------------
1 | name: build-docker-image
2 |
3 | on:
4 | push:
5 | branches:
6 | - "main"
7 |
8 | jobs:
9 | docker:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Set up QEMU
13 | uses: docker/setup-qemu-action@v2
14 |
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v2
17 |
18 | - name: Login to GitHub Container Registry
19 | uses: docker/login-action@v2
20 | with:
21 | registry: ghcr.io
22 | username: ${{ github.repository_owner }}
23 | password: ${{ secrets.GITHUB_TOKEN }}
24 |
25 | - name: Build and push
26 | uses: docker/build-push-action@v3
27 | with:
28 | push: true
29 | tags: ghcr.io/singlestore-labs/demo-realtime-digital-marketing:latest
30 |
--------------------------------------------------------------------------------
/web/src/rand.ts:
--------------------------------------------------------------------------------
1 | import { least } from "d3-array";
2 |
3 | import VENDORS from "@/static-data/vendors.json";
4 |
5 | export type Vendor = (typeof VENDORS)[number];
6 |
7 | export const randomChoice = (arr: ReadonlyArray): T =>
8 | arr[Math.floor(Math.random() * arr.length)];
9 |
10 | export const randomFloatInRange = (min: number, max: number) =>
11 | Math.random() * (max - min) + min;
12 |
13 | export const randomIntegerInRange = (min: number, max: number) =>
14 | Math.floor(randomFloatInRange(min, max));
15 |
16 | export const randomVendor = (): Vendor => {
17 | const lastVendor = VENDORS[VENDORS.length - 1];
18 | const r = randomIntegerInRange(0, lastVendor.cdf);
19 | const out = least(VENDORS, (v) =>
20 | v.cdf >= r ? v.cdf - r : Number.MAX_SAFE_INTEGER
21 | );
22 | if (out) {
23 | return out;
24 | }
25 | return lastVendor;
26 | };
27 |
--------------------------------------------------------------------------------
/web/src/components/customcomponents/loader/CenteringWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from "@chakra-ui/react";
2 | import classnames from "classnames";
3 | import * as React from "react";
4 |
5 | import "@/components/customcomponents/loader/centering-wrapper.scss";
6 |
7 | type Props = {
8 | className?: string;
9 | vertical?: boolean;
10 | children: React.ReactNode;
11 | };
12 |
13 | export const CenteringWrapper = ({ className, vertical, children }: Props) => {
14 | const classes = classnames(
15 | "single-common-components-centering-wrapper",
16 | "single-common-components-centering-wrapper.vertical",
17 | className,
18 | {
19 | vertical,
20 | }
21 | );
22 |
23 | return (
24 |
30 | {children}
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/web/src/components/SetupDatabaseButton.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text, useColorModeValue } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { useRecoilState } from "recoil";
4 |
5 | import { ResetSchemaButton } from "@/components/ResetSchemaButton";
6 | import { connectionDatabase } from "@/data/recoil";
7 |
8 | export const SetupDatabaseButton: React.FC = () => {
9 | const [databaseName] = useRecoilState(connectionDatabase);
10 |
11 | return (
12 |
13 |
14 | You don't have {databaseName} database. Please setup the schema for this
15 | application.
16 |
17 |
18 |
23 | Setup Database
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/gen/subscribers.go:
--------------------------------------------------------------------------------
1 | package gen
2 |
3 | import (
4 | "subscriber-sim/util"
5 |
6 | "github.com/ungerik/go3d/float64/vec2"
7 | )
8 |
9 | type Subscriber struct {
10 | Id int64
11 |
12 | Location vec2.T
13 | Velocity vec2.T
14 | TargetLocation vec2.T
15 |
16 | LastRequestDomain string
17 | LastPurchaseVendor string
18 | }
19 |
20 | func InitSubscribers(state *State, count int) {
21 | state.Subscribers = make([]Subscriber, count)
22 | offset := count * state.PartitionId
23 |
24 | for i := 0; i < count; i++ {
25 | loc := util.SampleUnitCircle(state.Rand)
26 | tgt := util.SampleUnitCircle(state.Rand)
27 | velocity := util.RandomVelocity(
28 | state.Rand,
29 | &loc,
30 | &tgt,
31 | state.MinSpeed,
32 | state.MaxSpeed,
33 | )
34 |
35 | state.Subscribers[i] = Subscriber{
36 | Id: int64(offset + i),
37 | Location: loc,
38 | Velocity: velocity,
39 | TargetLocation: tgt,
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/web/src/format.ts:
--------------------------------------------------------------------------------
1 | const SECOND_IN_MS = 1000;
2 | const MINUTE_IN_MS = 60 * SECOND_IN_MS;
3 | const HOUR_IN_MS = 60 * MINUTE_IN_MS;
4 | const DAY_IN_MS = 24 * HOUR_IN_MS;
5 |
6 | export const formatNumber = (num: number): string =>
7 | num.toLocaleString(undefined, { maximumFractionDigits: 2 });
8 |
9 | // formatMs converts a number of milliseconds to a string of the form "XhXXmXXsXXms"
10 | export const formatMs = (ms?: number): string => {
11 | if (!ms) {
12 | return "0s";
13 | }
14 | if (ms < SECOND_IN_MS) {
15 | return `${formatNumber(ms)}ms`;
16 | }
17 | if (ms < MINUTE_IN_MS) {
18 | return `${formatNumber(ms / SECOND_IN_MS)}s`;
19 | }
20 | if (ms < HOUR_IN_MS) {
21 | return `${Math.floor(ms / MINUTE_IN_MS)}m${formatMs(ms % MINUTE_IN_MS)}`;
22 | }
23 | if (ms < DAY_IN_MS) {
24 | return `${Math.floor(ms / HOUR_IN_MS)}h${formatMs(ms % HOUR_IN_MS)}`;
25 | }
26 | return `${Math.floor(ms / DAY_IN_MS)}d${formatMs(ms % DAY_IN_MS)}`;
27 | };
28 |
--------------------------------------------------------------------------------
/output/writer_test.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "subscriber-sim/util"
8 | "testing"
9 |
10 | "gocloud.dev/blob"
11 | _ "gocloud.dev/blob/memblob"
12 | )
13 |
14 | func TestBlobWriter(t *testing.T) {
15 | rnd := util.NewRandGen(0)
16 | genExt := NewExtensionGenerator()
17 |
18 | ctx := context.Background()
19 | b, err := blob.OpenBucket(ctx, "mem://")
20 | if err != nil {
21 | t.Fatal(err)
22 | }
23 | defer b.Close()
24 |
25 | jsonWriter := NewBlobWriter(b, &JSONEncoder{})
26 |
27 | batch := NewMockBatch(rnd, 0, 1000)
28 |
29 | err = jsonWriter.Write(ctx, batch, genExt)
30 | if err != nil {
31 | t.Fatal(err)
32 | }
33 |
34 | iter := b.List(nil)
35 | count := 0
36 | for {
37 | obj, err := iter.Next(ctx)
38 | if err == io.EOF {
39 | break
40 | }
41 | if err != nil {
42 | t.Fatal(err)
43 | }
44 |
45 | fmt.Println(obj.Key)
46 | count++
47 | }
48 |
49 | if count != 3 {
50 | t.Fatalf("expected 3 files, got %d", count)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/data/vendors_test.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | _ "embed"
5 | "math/rand"
6 | "reflect"
7 | "testing"
8 | )
9 |
10 | func TestChooseVendor(t *testing.T) {
11 | type args struct {
12 | rnd *rand.Rand
13 | }
14 | tests := []struct {
15 | name string
16 | args args
17 | want *Vendor
18 | }{
19 | {"verify that ChooseVendor returns a random vendor", args{rand.New(rand.NewSource(1234))}, &Vendor{
20 | Id: 403,
21 | Name: "Twitterwire",
22 | Domain: "twitterwire.gov",
23 | Category: "books",
24 | CDF: 216760,
25 | }},
26 | {"different seed different vendor", args{rand.New(rand.NewSource(5))}, &Vendor{
27 | Id: 675,
28 | Name: "InnoZ",
29 | Domain: "innoz.com",
30 | Category: "tools",
31 | CDF: 218500,
32 | }},
33 | }
34 | for _, tt := range tests {
35 | t.Run(tt.name, func(t *testing.T) {
36 | if got := ChooseVendor(tt.args.rnd); !reflect.DeepEqual(got, tt.want) {
37 | t.Errorf("ChooseVendor() = %v, want %v", got, tt.want)
38 | }
39 | })
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/web/src/components/customcomponents/Button.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps, useColorModeValue } from "@chakra-ui/react";
2 | import * as React from "react";
3 |
4 | // To make the primary button consistant everywhere we will not allow user to change color theme.
5 | // To have custom theme button we can directly use Button component.
6 | type customButtonProps = Omit<
7 | ButtonProps,
8 | "color" | "background" | "colorSchema"
9 | >;
10 |
11 | export const PrimaryButton = (props: customButtonProps) => {
12 | return (
13 |
20 | );
21 | };
22 |
23 | export const InvertedPrimaryButton = (props: customButtonProps) => {
24 | return (
25 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/sql/pipelines.sql:
--------------------------------------------------------------------------------
1 | CREATE OR REPLACE PIPELINE locations
2 | AS LOAD DATA S3 'singlestore-realtime-digital-marketing/${SCALE_FACTOR}/locations.*'
3 | CREDENTIALS '{}'
4 | CONFIG '{ "region": "us-east-1" }'
5 | MAX_PARTITIONS_PER_BATCH ${PARTITIONS}
6 | INTO PROCEDURE process_locations FORMAT PARQUET (
7 | subscriber_id <- subscriberid,
8 | offset_x <- offsetX,
9 | offset_y <- offsetY
10 | );
11 |
12 | CREATE OR REPLACE PIPELINE requests
13 | AS LOAD DATA S3 'singlestore-realtime-digital-marketing/${SCALE_FACTOR}/requests.*'
14 | CREDENTIALS '{}'
15 | CONFIG '{ "region": "us-east-1" }'
16 | MAX_PARTITIONS_PER_BATCH ${PARTITIONS}
17 | INTO PROCEDURE process_requests FORMAT PARQUET (
18 | subscriber_id <- subscriberid,
19 | domain <- domain
20 | );
21 |
22 | CREATE OR REPLACE PIPELINE purchases
23 | AS LOAD DATA S3 'singlestore-realtime-digital-marketing/${SCALE_FACTOR}/purchases.*'
24 | CREDENTIALS '{}'
25 | CONFIG '{ "region": "us-east-1" }'
26 | MAX_PARTITIONS_PER_BATCH ${PARTITIONS}
27 | INTO PROCEDURE process_purchases FORMAT PARQUET (
28 | subscriber_id <- subscriberid,
29 | vendor <- vendor
30 | );
31 |
32 | START ALL PIPELINES;
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/go/.devcontainer/base.Dockerfile
2 |
3 | # [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.16, 1.17, 1-bullseye, 1.16-bullseye, 1.17-bullseye, 1-buster, 1.16-buster, 1.17-buster
4 | ARG VARIANT="1.17-bullseye"
5 | FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
6 |
7 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
8 | ARG NODE_VERSION="none"
9 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
10 |
11 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
12 | && apt-get -y install --no-install-recommends mariadb-client
13 |
14 | # [Optional] Uncomment the next lines to use go get to install anything else you need
15 | # USER vscode
16 | # RUN go get -x
17 |
18 | # [Optional] Uncomment this line to install global node packages.
19 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
20 |
--------------------------------------------------------------------------------
/web/src/events.ts:
--------------------------------------------------------------------------------
1 | import * as PIXI from "pixi.js";
2 |
3 | export const onClick = (
4 | emitter: PIXI.utils.EventEmitter,
5 | handler: (evt: PIXI.InteractionEvent) => void
6 | ) => {
7 | let isDown = false;
8 | let startPosition = new PIXI.Point(0, 0);
9 | let startTime = 0;
10 |
11 | emitter.on("pointerdown", (evt: PIXI.InteractionEvent) => {
12 | isDown = true;
13 | startPosition = evt.data.global;
14 | startTime = performance.now();
15 | });
16 |
17 | emitter.on("pointerup", (evt: PIXI.InteractionEvent) => {
18 | if (!isDown) {
19 | return;
20 | }
21 | isDown = false;
22 |
23 | const delta = performance.now() - startTime;
24 | if (delta > 200) {
25 | return;
26 | }
27 |
28 | const endPosition = evt.data.global;
29 | const distance = Math.sqrt(
30 | Math.pow(endPosition.x - startPosition.x, 2) +
31 | Math.pow(endPosition.y - startPosition.y, 2)
32 | );
33 |
34 | if (distance > 10) {
35 | return;
36 | }
37 |
38 | handler(evt);
39 | });
40 |
41 | emitter.on("pointercancel", () => {
42 | isDown = false;
43 | });
44 | };
45 |
--------------------------------------------------------------------------------
/web/src/data/sql.ts:
--------------------------------------------------------------------------------
1 | // these files are parsed at build time in vite.config.js
2 | import FUNCTIONS from "@/sql/functions.sql";
3 | import PIPELINES from "@/sql/pipelines.sql";
4 | import PROCEDURES from "@/sql/procedures.sql";
5 | import TABLES from "@/sql/schema.sql";
6 | import SEED from "@/sql/seed.sql";
7 |
8 | export { FUNCTIONS, PIPELINES, PROCEDURES, SEED, TABLES };
9 |
10 | export const S3_BUCKET_NAME = "singlestore-realtime-digital-marketing";
11 |
12 | type SchemaObject = (typeof FUNCTIONS)[0];
13 |
14 | export const findSchemaObjectByName = (name: string): SchemaObject => {
15 | const search = [FUNCTIONS, PROCEDURES, TABLES, SEED];
16 |
17 | for (const schema of search) {
18 | const object = schema.find((o) => o.name === name);
19 | if (object) {
20 | return object;
21 | }
22 | }
23 |
24 | throw new Error("Could not find schema object: " + name);
25 | };
26 |
27 | export const findPipelineByName = (name: string): SchemaObject => {
28 | const object = PIPELINES.find((o) => o.name === name);
29 | if (object) {
30 | return object;
31 | }
32 |
33 | throw new Error("Could not find pipeline " + name);
34 | };
35 |
--------------------------------------------------------------------------------
/web/src/components/ConfigInput.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | FormControl,
3 | FormHelperText,
4 | FormLabel,
5 | Input,
6 | } from "@chakra-ui/react";
7 | import * as React from "react";
8 |
9 | type ConfigInputProps = {
10 | label: string;
11 | placeholder: string;
12 | value: string;
13 | setValue: (value: string) => void;
14 | helpText?: React.ReactNode;
15 | type?: "text" | "password" | "number";
16 | required?: boolean;
17 | };
18 |
19 | export const ConfigInput = ({
20 | label,
21 | placeholder,
22 | value,
23 | setValue,
24 | helpText,
25 | type = "text",
26 | required = false,
27 | }: ConfigInputProps) => (
28 |
29 |
30 | {label}
31 |
32 | setValue(e.target.value)}
40 | type={type}
41 | />
42 | {helpText ? (
43 | {helpText}
44 | ) : null}
45 |
46 | );
47 |
--------------------------------------------------------------------------------
/web/src/components/ScaleFactorSelector.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, FormLabel, Select } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { useRecoilState } from "recoil";
4 |
5 | import { configScaleFactor } from "@/data/recoil";
6 | import { getScaleFactor, ScaleFactors } from "@/scalefactors";
7 |
8 | export const ScaleFactorSelector = () => {
9 | const [scaleFactor, setScaleFactor] = useRecoilState(configScaleFactor);
10 |
11 | return (
12 |
13 |
19 | Scale Factor
20 |
21 |
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/util/rand.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "math/rand"
5 |
6 | "github.com/ungerik/go3d/float64/vec2"
7 | )
8 |
9 | type RandGen struct {
10 | rnd *rand.Rand
11 | }
12 |
13 | type RandWithSource struct {
14 | *rand.Rand
15 | rand.Source
16 | }
17 |
18 | func NewRandGen(seed int64) *RandGen {
19 | return &RandGen{
20 | rnd: rand.New(rand.NewSource(seed)),
21 | }
22 | }
23 |
24 | func (g *RandGen) Next() RandWithSource {
25 | s := rand.NewSource(g.rnd.Int63())
26 | r := rand.New(s)
27 | return RandWithSource{
28 | Rand: r,
29 | Source: s,
30 | }
31 | }
32 |
33 | func (g *RandGen) List(n int) []RandWithSource {
34 | out := make([]RandWithSource, n)
35 | for i := 0; i < n; i++ {
36 | out[i] = g.Next()
37 | }
38 | return out
39 | }
40 |
41 | type RandInterface interface {
42 | Float64() float64
43 | Intn(int) int
44 | }
45 |
46 | // SampleUnitCircle picks a random point on the unit circle
47 | func SampleUnitCircle(rnd RandInterface) vec2.T {
48 | return vec2.T{rnd.Float64() - 0.5, rnd.Float64() - 0.5}
49 | }
50 |
51 | func RandomBetween(rnd RandInterface, a, b float64) float64 {
52 | return (rnd.Float64() * (b - a)) + a
53 | }
54 |
55 | func RandomVelocity(rnd RandInterface, from, to *vec2.T, min, max float64) vec2.T {
56 | v := vec2.Sub(to, from)
57 | v.Scale(RandomBetween(rnd, min, max))
58 | return v
59 | }
60 |
--------------------------------------------------------------------------------
/web/src/analytics.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useLocation } from "react-router-dom";
3 | import { useRecoilState } from "recoil";
4 |
5 | import { userSessionID } from "@/data/recoil";
6 | import { useConnectionState } from "@/view/hooks/hooks";
7 |
8 | export function trackAnalyticsEvent(
9 | event: string,
10 | params?: Record
11 | ) {
12 | if (window.analytics) {
13 | window.analytics.track(`rtdm-${event}`, params);
14 | } else {
15 | console.warn("window.analytics is not defined");
16 | }
17 | }
18 |
19 | export function useAnalytics() {
20 | const location = useLocation();
21 | const { connectionType } = useConnectionState();
22 | const [userID, setUserID] = useRecoilState(userSessionID);
23 |
24 | React.useEffect(() => {
25 | const { pathname } = location;
26 | trackAnalyticsEvent("change-page", { pathname });
27 | }, [location]);
28 |
29 | React.useEffect(() => {
30 | if (connectionType) {
31 | trackAnalyticsEvent("connection-successful", { connectionType });
32 | }
33 | }, [connectionType]);
34 |
35 | React.useEffect(() => {
36 | if (window.analytics) {
37 | window.analytics.identify(userID);
38 | } else {
39 | console.warn("window.analytics is not defined");
40 | }
41 | }, [userID, setUserID]);
42 | }
43 |
--------------------------------------------------------------------------------
/data/vendors.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | _ "embed"
5 | "encoding/json"
6 | "math"
7 | "sort"
8 | "strings"
9 | "subscriber-sim/util"
10 | )
11 |
12 | type Vendor struct {
13 | Id int
14 | Name string
15 | Domain string
16 | Category string
17 | CDF float64
18 | }
19 |
20 | var (
21 | //go:embed vendors.json
22 | vendors_raw []byte
23 | vendorMaxTotal int
24 | Vendors []Vendor
25 | )
26 |
27 | func ChooseVendor(rnd util.RandInterface) *Vendor {
28 | r := rnd.Intn(vendorMaxTotal) + 1
29 | i := sort.Search(len(Vendors), func(i int) bool {
30 | return int(Vendors[i].CDF) >= r
31 | })
32 | if i < len(Vendors) {
33 | return &Vendors[i]
34 | }
35 | return nil
36 | }
37 |
38 | func init() {
39 | var vendors []struct {
40 | Id int
41 | Vendor string
42 | Tld string
43 | Category string
44 | CDF float64
45 | }
46 |
47 | err := json.Unmarshal(vendors_raw, &vendors)
48 | if err != nil {
49 | panic(err)
50 | }
51 |
52 | Vendors = make([]Vendor, 0, len(vendors))
53 |
54 | for _, v := range vendors {
55 | Vendors = append(Vendors, Vendor{
56 | Id: v.Id,
57 | Name: v.Vendor,
58 | Domain: strings.ToLower(v.Vendor) + "." + v.Tld,
59 | Category: v.Category,
60 | CDF: v.CDF,
61 | })
62 | }
63 |
64 | vendorMaxTotal = int(math.Ceil(Vendors[len(Vendors)-1].CDF))
65 | }
66 |
--------------------------------------------------------------------------------
/web/src/view/hooks/useSimulationMonitor.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useRecoilValue } from "recoil";
3 |
4 | import {
5 | checkPlans,
6 | ensurePipelinesAreRunning,
7 | ensurePipelinesExist,
8 | truncateTimeseriesTables,
9 | } from "@/data/queries";
10 | import { configScaleFactor, connectionConfig } from "@/data/recoil";
11 | import { useConnectionState, useTick } from "@/view/hooks/hooks";
12 | import { useSession } from "@/view/hooks/useSession";
13 |
14 | const TICK_INTERVAL_MONITOR = 10 * 1000;
15 |
16 | export const useSimulationMonitor = (enabled: boolean) => {
17 | const config = useRecoilValue(connectionConfig);
18 | const scaleFactor = useRecoilValue(configScaleFactor);
19 | const { initialized } = useConnectionState();
20 | const { session } = useSession();
21 |
22 | const monitorTick = React.useCallback(
23 | (ctx: AbortController) => {
24 | const cfgWithCtx = { ...config, ctx };
25 | return Promise.all([
26 | ensurePipelinesExist(cfgWithCtx, scaleFactor),
27 | ensurePipelinesAreRunning(cfgWithCtx),
28 | truncateTimeseriesTables(cfgWithCtx, scaleFactor),
29 | checkPlans(cfgWithCtx),
30 | ]);
31 | },
32 | [config, scaleFactor]
33 | );
34 |
35 | useTick(monitorTick, {
36 | name: "SimulatorMonitor",
37 | enabled: initialized && enabled && session.isController,
38 | intervalMS: TICK_INTERVAL_MONITOR,
39 | });
40 | };
41 |
--------------------------------------------------------------------------------
/web/src/components/OfferMap.tsx:
--------------------------------------------------------------------------------
1 | import { Omit } from "framer-motion/types/types";
2 | import { Bounds } from "pigeon-maps";
3 | import * as React from "react";
4 | import { useRecoilValue } from "recoil";
5 | import useSWR from "swr";
6 |
7 | import { Heatmap } from "@/components/HeatMap";
8 | import { PixiMapProps } from "@/components/PixiMap";
9 | import { Offer, queryOffersInBounds } from "@/data/queries";
10 | import { connectionConfig } from "@/data/recoil";
11 | import { useConnectionState } from "@/view/hooks/hooks";
12 |
13 | const MAX_OFFERS = 1000;
14 |
15 | const useCells = (bounds: Bounds, callback: (cells: Array) => void) => {
16 | const config = useRecoilValue(connectionConfig);
17 | const { initialized } = useConnectionState();
18 |
19 | useSWR(
20 | ["offers", config, initialized, bounds],
21 | () => queryOffersInBounds(config, MAX_OFFERS, bounds),
22 | {
23 | isPaused: () => !initialized,
24 | onSuccess: callback,
25 | }
26 | );
27 | };
28 |
29 | type Props = Omit, "useRenderer" | "options">;
30 |
31 | export const OfferMap = (props: Props) => {
32 | return (
33 | "#553ACF"}
37 | getCellConfig={(cell: Offer) => {
38 | return {
39 | value: 1,
40 | wktPolygon: cell.notificationZone,
41 | };
42 | }}
43 | />
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/go
3 | {
4 | "name": "Go",
5 | "build": {
6 | "dockerfile": "Dockerfile",
7 | "args": {
8 | // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.17
9 | // Append -bullseye or -buster to pin to an OS version.
10 | // Use -bullseye variants on local arm64/Apple Silicon.
11 | "VARIANT": "1.17",
12 | // Options
13 | "NODE_VERSION": "lts/*"
14 | }
15 | },
16 | "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"],
17 | // Set *default* container specific settings.json values on container create.
18 | "settings": {
19 | "go.toolsManagement.checkForUpdates": "local",
20 | "go.useLanguageServer": true,
21 | "go.gopath": "/go",
22 | "go.goroot": "/usr/local/go"
23 | },
24 | // Add the IDs of extensions you want installed when the container is created.
25 | "extensions": ["golang.Go"],
26 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
27 | // "forwardPorts": [],
28 | // Use 'postCreateCommand' to run commands after the container is created.
29 | // "postCreateCommand": "go version",
30 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
31 | "remoteUser": "vscode"
32 | }
33 |
--------------------------------------------------------------------------------
/gen/update.go:
--------------------------------------------------------------------------------
1 | package gen
2 |
3 | import (
4 | "subscriber-sim/data"
5 | "subscriber-sim/util"
6 |
7 | "github.com/ungerik/go3d/float64/vec2"
8 | )
9 |
10 | // UpdateSubscribers updates each subscriber's location, last request, and last
11 | // purchase
12 | func UpdateSubscribers(state *State) {
13 | for i := range state.Subscribers {
14 | subscriber := &state.Subscribers[i]
15 |
16 | // if the distance to the target is less than or equal to our velocity
17 | // we need to pick a new target location and velocity
18 | d := vec2.Sub(&subscriber.TargetLocation, &subscriber.Location)
19 | if d.Length() <= subscriber.Velocity.Length() {
20 | subscriber.TargetLocation = util.SampleUnitCircle(state.Rand)
21 | subscriber.Velocity = util.RandomVelocity(
22 | state.Rand,
23 | &subscriber.Location,
24 | &subscriber.TargetLocation,
25 | state.MinSpeed,
26 | state.MaxSpeed,
27 | )
28 | } else {
29 | // otherwise, move towards the target location
30 | subscriber.Location.Add(&subscriber.Velocity)
31 | }
32 |
33 | if state.RequestProb > state.Rand.Float64() {
34 | vendor := data.ChooseVendor(state.Rand)
35 | subscriber.LastRequestDomain = vendor.Domain
36 | } else {
37 | subscriber.LastRequestDomain = ""
38 | }
39 |
40 | if state.PurchaseProb > state.Rand.Float64() {
41 | vendor := data.ChooseVendor(state.Rand)
42 | subscriber.LastPurchaseVendor = vendor.Name
43 | } else {
44 | subscriber.LastPurchaseVendor = ""
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/output/blob.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "context"
5 |
6 | "gocloud.dev/blob"
7 | )
8 |
9 | type BlobWriter struct {
10 | bucket *blob.Bucket
11 | enc BatchEncoder
12 | }
13 |
14 | func NewBlobWriter(bucket *blob.Bucket, enc BatchEncoder) *BlobWriter {
15 | return &BlobWriter{
16 | bucket: bucket,
17 | enc: enc,
18 | }
19 | }
20 |
21 | func (w *BlobWriter) Write(ctx context.Context, batch Batch, genExt ExtensionGenerator) error {
22 | extension := genExt(batch, w.enc)
23 |
24 | writer, err := w.bucket.NewWriter(ctx, "locations"+extension, &blob.WriterOptions{})
25 | if err != nil {
26 | return err
27 | }
28 | err = w.enc.EncodeLocations(batch.SeqId(), batch.Locations(), writer)
29 | if err != nil {
30 | return err
31 | }
32 | err = writer.Close()
33 | if err != nil {
34 | return err
35 | }
36 |
37 | writer, err = w.bucket.NewWriter(ctx, "requests"+extension, &blob.WriterOptions{})
38 | if err != nil {
39 | return err
40 | }
41 | err = w.enc.EncodeRequests(batch.SeqId(), batch.Requests(), writer)
42 | if err != nil {
43 | return err
44 | }
45 | err = writer.Close()
46 | if err != nil {
47 | return err
48 | }
49 |
50 | writer, err = w.bucket.NewWriter(ctx, "purchases"+extension, &blob.WriterOptions{})
51 | if err != nil {
52 | return err
53 | }
54 | err = w.enc.EncodePurchases(batch.SeqId(), batch.Purchases(), writer)
55 | if err != nil {
56 | return err
57 | }
58 | err = writer.Close()
59 | if err != nil {
60 | return err
61 | }
62 |
63 | return nil
64 | }
65 |
--------------------------------------------------------------------------------
/.github/workflows/on-push.yml:
--------------------------------------------------------------------------------
1 | name: on push
2 | on: [push]
3 | jobs:
4 | test-go:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/setup-go@v3
8 | with:
9 | go-version: "^1.17"
10 |
11 | - name: Checkout source
12 | uses: actions/checkout@v3
13 |
14 | - uses: actions/cache@v3
15 | with:
16 | path: |
17 | ~/go/pkg/mod
18 | ~/.cache/go-build
19 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
20 | restore-keys: |
21 | ${{ runner.os }}-go-
22 |
23 | - name: Test
24 | run: go test ./...
25 |
26 | deploy-web:
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout source
30 | uses: actions/checkout@v3
31 |
32 | - name: Install & cache web dependencies
33 | uses: bahmutov/npm-install@v1
34 | with:
35 | working-directory: web
36 |
37 | - name: Prettier
38 | run: yarn prettier --check .
39 | working-directory: web
40 |
41 | - name: Lint
42 | run: yarn eslint .
43 | working-directory: web
44 |
45 | - name: Check typescript
46 | run: yarn run tsc
47 | working-directory: web
48 |
49 | - name: Build
50 | run: yarn run vite build
51 | working-directory: web
52 |
53 | - name: Deploy
54 | if: github.ref == 'refs/heads/main'
55 | uses: JamesIves/github-pages-deploy-action@v4.4.1
56 | with:
57 | folder: web/dist
58 |
--------------------------------------------------------------------------------
/output/mock_test.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "math/rand"
5 | "subscriber-sim/data"
6 | "subscriber-sim/util"
7 |
8 | "github.com/ungerik/go3d/float64/vec2"
9 | )
10 |
11 | type MockBatch struct {
12 | partitionId int
13 | seqId int64
14 | locs []Location
15 | reqs []Request
16 | purs []Purchase
17 | }
18 |
19 | func NewMockBatch(rnd *util.RandGen, partitionId int, size int) *MockBatch {
20 | locs := make([]Location, size)
21 | reqs := make([]Request, size)
22 | purs := make([]Purchase, size)
23 |
24 | for i := 0; i < size; i++ {
25 | vendor := data.ChooseVendor(rnd.Next())
26 |
27 | offset := vec2.T{rand.Float64(), rand.Float64()}
28 | locs[i] = Location{
29 | SubscriberId: int64(i),
30 | Offset: offset,
31 | }
32 | reqs[i] = Request{
33 | SubscriberId: int64(i),
34 | Domain: vendor.Domain,
35 | }
36 | purs[i] = Purchase{
37 | SubscriberId: int64(i),
38 | Vendor: vendor.Name,
39 | }
40 | }
41 |
42 | return &MockBatch{
43 | partitionId: partitionId,
44 | seqId: 0,
45 | locs: locs,
46 | reqs: reqs,
47 | purs: purs,
48 | }
49 | }
50 |
51 | func (m MockBatch) PartitionId() int {
52 | return m.partitionId
53 | }
54 |
55 | func (m MockBatch) SeqId() int64 {
56 | return m.seqId
57 | }
58 |
59 | func (m MockBatch) Locations() []Location {
60 | return m.locs
61 | }
62 |
63 | func (m MockBatch) Requests() []Request {
64 | return m.reqs
65 | }
66 |
67 | func (m MockBatch) Purchases() []Purchase {
68 | return m.purs
69 | }
70 |
--------------------------------------------------------------------------------
/web/src/view/hooks/useSession.ts:
--------------------------------------------------------------------------------
1 | import { useRecoilValue } from "recoil";
2 | import useSWR from "swr";
3 | import { v4 as uuidv4 } from "uuid";
4 |
5 | import { updateSessions } from "@/data/queries";
6 | import { connectionConfig, resettingSchema } from "@/data/recoil";
7 | import { useConnectionState } from "@/view/hooks/hooks";
8 |
9 | const SESSION_ID = (() => {
10 | const newUUID = () =>
11 | crypto && crypto.randomUUID ? crypto.randomUUID() : uuidv4();
12 |
13 | let sessionID: string;
14 |
15 | if (import.meta.hot) {
16 | const { data } = import.meta.hot;
17 | if (!("SESSION_ID" in data)) {
18 | data.SESSION_ID = newUUID();
19 | }
20 | sessionID = data.SESSION_ID;
21 | } else {
22 | sessionID = newUUID();
23 | }
24 |
25 | return sessionID;
26 | })();
27 |
28 | const SESSION_LEASE_SECONDS = 60;
29 |
30 | export const useSession = () => {
31 | const config = useRecoilValue(connectionConfig);
32 | const isResettingSchema = useRecoilValue(resettingSchema);
33 | const { connected, initialized } = useConnectionState();
34 | const { data, mutate } = useSWR(
35 | [config, "useSession"],
36 | () => updateSessions(config, SESSION_ID, SESSION_LEASE_SECONDS),
37 | {
38 | refreshInterval: 1000,
39 | isPaused: () => isResettingSchema || !connected || !initialized,
40 | }
41 | );
42 | return {
43 | session: data || {
44 | sessionID: SESSION_ID,
45 | isController: false,
46 | expiresAt: new Date(Date.now() + SESSION_LEASE_SECONDS * 1000),
47 | },
48 | refresh: mutate,
49 | };
50 | };
51 |
--------------------------------------------------------------------------------
/web/src/view/hooks/useSimulator.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useRecoilValue } from "recoil";
3 |
4 | import { runMatchingProcess, runUpdateSegments } from "@/data/queries";
5 | import { connectionConfig } from "@/data/recoil";
6 | import { toISOStringNoTZ } from "@/datetime";
7 | import { useConnectionState, useTick } from "@/view/hooks/hooks";
8 | import { useSession } from "@/view/hooks/useSession";
9 |
10 | const TICK_INTERVAL_MATCH = 1 * 1000;
11 | const TICK_INTERVAL_SEGMENTS = 1 * 1000;
12 |
13 | export const useSimulator = (enabled: boolean) => {
14 | const config = useRecoilValue(connectionConfig);
15 | const { initialized } = useConnectionState();
16 | const timestampCursor = React.useRef(toISOStringNoTZ(new Date(0)));
17 | const { session } = useSession();
18 |
19 | const matchingTick = React.useCallback(
20 | (ctx: AbortController) => runMatchingProcess({ ...config, ctx }, "minute"),
21 | [config]
22 | );
23 |
24 | useTick(matchingTick, {
25 | name: "SimulatorMatcher",
26 | enabled: initialized && enabled && session.isController,
27 | intervalMS: TICK_INTERVAL_MATCH,
28 | });
29 |
30 | const updateSegmentsTick = React.useCallback(
31 | async (ctx: AbortController) => {
32 | timestampCursor.current = await runUpdateSegments(
33 | { ...config, ctx },
34 | timestampCursor.current
35 | );
36 | },
37 | [config]
38 | );
39 |
40 | useTick(updateSegmentsTick, {
41 | name: "SimulatorUpdateSegments",
42 | enabled: initialized && enabled && session.isController,
43 | intervalMS: TICK_INTERVAL_SEGMENTS,
44 | });
45 | };
46 |
--------------------------------------------------------------------------------
/web/src/data/constants.ts:
--------------------------------------------------------------------------------
1 | import { City } from "@/data/queries";
2 |
3 | // The selectableCities variable hardcodes cities details that user can select.
4 | // Make sure the values here matches the values stored in worldcities database in martech.
5 | export const SELECTABLE_CITIES_DATA: Array = [
6 | {
7 | id: 14,
8 | name: "Dubai",
9 | centerLat: 25.0656999896784,
10 | centerLon: 55.17128003951277,
11 | diameter: 0.4,
12 | },
13 | {
14 | id: 120658,
15 | name: "New York City",
16 | centerLat: 40.71427003,
17 | centerLon: -74.00597003,
18 | diameter: 0.4,
19 | },
20 | {
21 | id: 4658,
22 | name: "Sydney",
23 | centerLat: -33.86785000935749,
24 | centerLon: 151.20732002056695,
25 | diameter: 0.4,
26 | },
27 | {
28 | id: 45042,
29 | name: "London",
30 | centerLat: 51.50852998,
31 | centerLon: -0.12574004,
32 | diameter: 0.4,
33 | },
34 | {
35 | id: 37679,
36 | name: "Paris",
37 | centerLat: 48.85340997,
38 | centerLon: 2.34879996,
39 | diameter: 0.4,
40 | },
41 | {
42 | id: 33174,
43 | name: "Barcelona",
44 | centerLat: 41.38878998,
45 | centerLon: 2.15899,
46 | diameter: 0.4,
47 | },
48 | {
49 | id: 49551,
50 | name: "Hong Kong",
51 | centerLat: 22.27831998876015,
52 | centerLon: 114.17469000111508,
53 | diameter: 0.4,
54 | },
55 | {
56 | id: 68449,
57 | name: "Tokyo",
58 | centerLat: 35.689500027072434,
59 | centerLon: 139.6917100207488,
60 | diameter: 0.4,
61 | },
62 | {
63 | id: 103513,
64 | name: "Singapore",
65 | centerLat: 1.2896699943544807,
66 | centerLon: 103.8500700295648,
67 | diameter: 0.4,
68 | },
69 | ];
70 |
--------------------------------------------------------------------------------
/web/src/assets/singlestore-logo-dark.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/web/src/assets/singlestore-logo-filled-sm.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/web/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Link, Text, useColorModeValue } from "@chakra-ui/react";
2 | import * as React from "react";
3 |
4 | import {
5 | GithubIconButton,
6 | LinkedinIconButton,
7 | TwitterIconButton,
8 | } from "./IconLinks";
9 |
10 | const SocialMediaSection = () => {
11 | const handleRedirect = (
12 | e: React.MouseEvent
13 | ) => {
14 | window.open(e.currentTarget.value, "_blank");
15 | };
16 |
17 | return (
18 |
19 |
20 | Repository
21 |
22 |
23 |
24 |
25 | Share on
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export const Footer = () => {
34 | return (
35 |
43 |
49 |
50 | Real-Time Digital Marketing is a demo application running on{" "}
51 | SingleStoreDB
52 |
53 |
54 |
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/output/json.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "encoding/json"
5 | "io"
6 | )
7 |
8 | type JSONEncoder struct {
9 | }
10 |
11 | func (e *JSONEncoder) Extension() string {
12 | return "json"
13 | }
14 |
15 | func (e *JSONEncoder) EncodeLocations(seqId int64, rows []Location, w io.Writer) error {
16 | enc := json.NewEncoder(w)
17 | obj := map[string]interface{}{
18 | "seqId": nil,
19 | "subscriberid": nil,
20 | "offsetX": nil,
21 | "offsetY": nil,
22 | }
23 |
24 | for i := range rows {
25 | obj["seqId"] = seqId
26 | obj["subscriberid"] = rows[i].SubscriberId
27 | obj["offsetX"] = rows[i].Offset[0]
28 | obj["offsetY"] = rows[i].Offset[1]
29 |
30 | if err := enc.Encode(obj); err != nil {
31 | return err
32 | }
33 | }
34 | return nil
35 | }
36 |
37 | func (e *JSONEncoder) EncodeRequests(seqId int64, rows []Request, w io.Writer) error {
38 | enc := json.NewEncoder(w)
39 | obj := map[string]interface{}{
40 | "seqId": nil,
41 | "subscriberid": nil,
42 | "domain": nil,
43 | }
44 |
45 | for i := range rows {
46 | obj["seqId"] = seqId
47 | obj["subscriberid"] = rows[i].SubscriberId
48 | obj["domain"] = []byte(rows[i].Domain)
49 |
50 | if err := enc.Encode(obj); err != nil {
51 | return err
52 | }
53 | }
54 | return nil
55 | }
56 |
57 | func (e *JSONEncoder) EncodePurchases(seqId int64, rows []Purchase, w io.Writer) error {
58 | enc := json.NewEncoder(w)
59 | obj := map[string]interface{}{
60 | "seqId": nil,
61 | "subscriberid": nil,
62 | "vendor": nil,
63 | }
64 |
65 | for i := range rows {
66 | obj["seqId"] = seqId
67 | obj["subscriberid"] = rows[i].SubscriberId
68 | obj["vendor"] = []byte(rows[i].Vendor)
69 | if err := enc.Encode(obj); err != nil {
70 | return err
71 | }
72 | }
73 | return nil
74 | }
75 |
--------------------------------------------------------------------------------
/sql/seed.sql:
--------------------------------------------------------------------------------
1 | CREATE OR REPLACE PIPELINE offers AS
2 | LOAD DATA S3 'singlestore-realtime-digital-marketing/offers.ndjson'
3 | CREDENTIALS '{}' CONFIG '{ "region": "us-east-1" }'
4 | SKIP DUPLICATE KEY ERRORS
5 | INTO TABLE offers
6 | FORMAT JSON (
7 | offer_id <- offer_id,
8 | customer <- customer,
9 | enabled <- enabled,
10 | notification_zone <- notification_zone,
11 | segment_ids <- segment_ids,
12 | notification_content <- notification_content,
13 | notification_target <- notification_target,
14 | maximum_bid_cents <- maximum_bid_cents
15 | );
16 |
17 | START PIPELINE IF NOT RUNNING offers;
18 |
19 | CREATE OR REPLACE PIPELINE segments AS
20 | LOAD DATA S3 'singlestore-realtime-digital-marketing/segments.ndjson'
21 | CREDENTIALS '{}' CONFIG '{ "region": "us-east-1" }'
22 | SKIP DUPLICATE KEY ERRORS
23 | INTO TABLE segments
24 | FORMAT JSON (
25 | segment_id <- segment_id,
26 | valid_interval <- valid_interval,
27 | filter_kind <- filter_kind,
28 | filter_value <- filter_value
29 | );
30 |
31 | START PIPELINE IF NOT RUNNING segments;
32 |
33 | INSERT INTO cities (city_id, city_name, center, diameter)
34 | VALUES (120658, "New York", "POINT(-73.993562 40.727063)", 0.04)
35 | ON DUPLICATE KEY UPDATE
36 | city_name = VALUES(city_name),
37 | center = VALUES(center),
38 | diameter = VALUES(diameter);
39 |
40 | CREATE OR REPLACE PIPELINE worldcities AS
41 | LOAD DATA S3 'singlestore-realtime-digital-marketing/cities.ndjson'
42 | CREDENTIALS '{}' CONFIG '{ "region": "us-east-1" }'
43 | SKIP DUPLICATE KEY ERRORS
44 | INTO TABLE worldcities
45 | FORMAT JSON (
46 | city_id <- id,
47 | city_name <- name,
48 | @lat <- lat,
49 | @lng <- lng
50 | )
51 | SET center = GEOGRAPHY_POINT(@lng, @lat);
52 |
53 | START PIPELINE IF NOT RUNNING worldcities;
--------------------------------------------------------------------------------
/web/src/geo.ts:
--------------------------------------------------------------------------------
1 | import { Bounds } from "pigeon-maps";
2 |
3 | export type Polygon = Array<[number, number]>;
4 |
5 | export const polygonToSQL = (polygon: Polygon) => {
6 | const points = polygon.map((p) => `${p[0]} ${p[1]}`).join(",");
7 | return `POLYGON((${points}))`;
8 | };
9 |
10 | export const boundsToWKTPolygon = (bounds: Bounds) => {
11 | const [minLat, maxLon] = bounds.ne;
12 | const [maxLat, minLon] = bounds.sw;
13 |
14 | return polygonToSQL([
15 | [minLon, minLat],
16 | [maxLon, minLat],
17 | [maxLon, maxLat],
18 | [minLon, maxLat],
19 | [minLon, minLat],
20 | ]);
21 | };
22 |
23 | const WKT_POLYGON_PREFIX = "POLYGON((";
24 | const WKT_POLYGON_SUFFIX = "))";
25 |
26 | export const WKTPolygonToPolygon = (polygon: string): Polygon => {
27 | // NOTE: this function only handles simple polygons (single segment)
28 | // example: POLYGON((-74.04367371 40.69802040, -74.04166051 40.69645297, -74.03991248 40.69770204, -74.04174768 40.69914786, -74.04124261 40.69953674, -74.04025555 40.69880482, -74.03771124 40.69934404, -74.03938278 40.70057769, -74.03995040 40.70089063, -74.04367371 40.69802040))
29 | // a polygon with multiple segments will result in some points being NaN
30 | if (
31 | polygon.startsWith(WKT_POLYGON_PREFIX) &&
32 | polygon.endsWith(WKT_POLYGON_SUFFIX)
33 | ) {
34 | const points = polygon
35 | .slice(WKT_POLYGON_PREFIX.length, -WKT_POLYGON_SUFFIX.length)
36 | .split(",");
37 | return points.map((p) => {
38 | const [lon, lat] = p.trim().split(" ");
39 | return [parseFloat(lon), parseFloat(lat)];
40 | });
41 | }
42 | throw new Error(`Invalid WKT polygon: ${polygon}`);
43 | };
44 |
45 | export const boundsContains = (b: Bounds, lat: number, lng: number) => {
46 | return lat <= b.ne[0] && lng <= b.ne[1] && lat >= b.sw[0] && lng >= b.sw[1];
47 | };
48 |
--------------------------------------------------------------------------------
/web/src/scalefactors.ts:
--------------------------------------------------------------------------------
1 | export type ScaleFactor = {
2 | name: string;
3 | maxRows: number;
4 | prefix: string;
5 | partitions: number;
6 | };
7 |
8 | export const ScaleFactors: Array = [
9 | {
10 | name: "micro",
11 | maxRows: 1_000_000,
12 | prefix: "v2/1k-2p",
13 | partitions: 2,
14 | },
15 | {
16 | name: "tiny",
17 | maxRows: 5_000_000,
18 | prefix: "v2/10k-2p",
19 | partitions: 2,
20 | },
21 | {
22 | name: "s00",
23 | maxRows: 10_000_000,
24 | prefix: "v2/10k-2p",
25 | partitions: 2,
26 | },
27 | {
28 | name: "s0",
29 | maxRows: 20_000_000,
30 | prefix: "v2/100k-4p",
31 | partitions: 4,
32 | },
33 | {
34 | name: "s1",
35 | maxRows: 40_000_000,
36 | prefix: "v2/100k-8p",
37 | partitions: 8,
38 | },
39 | {
40 | name: "s2",
41 | maxRows: 160_000_000,
42 | prefix: "v2/1m-16p",
43 | partitions: 16,
44 | },
45 | {
46 | name: "s4",
47 | maxRows: 320_000_000,
48 | prefix: "v2/1m-32p",
49 | partitions: 16,
50 | },
51 | {
52 | name: "s8",
53 | maxRows: 640_000_000,
54 | prefix: "v2/1m-64p",
55 | partitions: 64,
56 | },
57 | {
58 | name: "s10",
59 | maxRows: 1_000_000_000,
60 | prefix: "v2/10m-80p",
61 | partitions: 80,
62 | },
63 | ];
64 |
65 | // getScaleFactor looks up the ScaleFactor for the given name
66 | export const getScaleFactor = (name: string): ScaleFactor =>
67 | ScaleFactors.find((sf) => sf.name === name) || ScaleFactors[0];
68 |
69 | export const defaultScaleFactor = getScaleFactor("micro");
70 |
71 | export const pickScaleFactor = (numPartitions: number): ScaleFactor => {
72 | // pick the scale factor with the largest number of partitions <= numPartitions
73 | return (
74 | [...ScaleFactors]
75 | .sort((a, b) => b.partitions - a.partitions)
76 | .find((sf) => sf.partitions <= numPartitions) ||
77 | ScaleFactors[ScaleFactors.length - 1]
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/web/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider, IToast, useToast } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import ReactDOM from "react-dom";
4 | import { BrowserRouter } from "react-router-dom";
5 | import { RecoilRoot, useRecoilValue } from "recoil";
6 | import { SWRConfig } from "swr";
7 |
8 | import App from "@/App";
9 | import { ClientErrorBoundary, ErrorBoundary } from "@/components/ErrorHandler";
10 | import { chakraTheme } from "@/components/theme";
11 | import { resettingSchema } from "@/data/recoil";
12 |
13 | const SWRWrapper = ({ children }: { children: React.ReactNode }) => {
14 | const isResettingSchema = useRecoilValue(resettingSchema);
15 | const toast = useToast();
16 | const handleError = (err: Error) => {
17 | if (isResettingSchema) {
18 | console.warn("Ignoring error while resetting schema", err);
19 | } else {
20 | console.error(err);
21 | const id = "swr-error";
22 | const t: IToast = {
23 | id: "swr-error",
24 | title: "An error occurred",
25 | description: err.message,
26 | status: "error",
27 | duration: 5000,
28 | isClosable: true,
29 | };
30 | if (toast.isActive(id)) {
31 | toast.update(id, t);
32 | } else {
33 | toast({ id, ...t });
34 | }
35 | }
36 | };
37 |
38 | return {children};
39 | };
40 |
41 | ReactDOM.render(
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | ,
57 | document.getElementById("root")
58 | );
59 |
--------------------------------------------------------------------------------
/web/src/data/sqlgen.ts:
--------------------------------------------------------------------------------
1 | import dedent from "ts-dedent";
2 |
3 | import { SQLValue } from "@/data/client";
4 |
5 | export interface CompiledQuery {
6 | sql: string;
7 | params: Array;
8 | }
9 |
10 | export type SQLChunk =
11 | | string
12 | | {
13 | sql: string;
14 | params?: Array;
15 | };
16 |
17 | export type WithStatement = {
18 | with: Array<[string, SQLChunk]>;
19 | base: SQLChunk;
20 | };
21 |
22 | export type InsertStatement = {
23 | table: string;
24 | options?: {
25 | replace: boolean;
26 | };
27 | columns: Array;
28 | tuples: Array>;
29 | };
30 |
31 | export const compileInsert = (stmt: InsertStatement): CompiledQuery => {
32 | const { table, columns, tuples, options } = stmt;
33 |
34 | const tupleSQL = `(${columns.map(() => "?").join(",")})`;
35 | const valuesSQL = tuples.map(() => tupleSQL).join(",");
36 |
37 | return {
38 | sql: dedent`
39 | ${options?.replace ? "REPLACE" : "INSERT"} INTO ${table}
40 | (${columns.join(", ")})
41 | VALUES
42 | ${valuesSQL}
43 | `,
44 | params: tuples.flat(),
45 | };
46 | };
47 |
48 | export const compileChunk = (stmt: SQLChunk): CompiledQuery => {
49 | return {
50 | sql: typeof stmt === "string" ? stmt : stmt.sql,
51 | params: typeof stmt === "string" ? [] : stmt.params || [],
52 | };
53 | };
54 |
55 | export const compileWithStatement = (stmt: WithStatement): CompiledQuery => {
56 | const { with: fragments, base } = stmt;
57 |
58 | const fragmentsParams: Array = [];
59 | const fragmentsSQL = fragments
60 | .map(([name, fragment]) => {
61 | const { sql, params } = compileChunk(fragment);
62 | fragmentsParams.push(...params);
63 | return `${name} AS (${dedent(sql)})`;
64 | })
65 | .join(",\n");
66 |
67 | const { sql, params } = compileChunk(base);
68 | return {
69 | sql: dedent`
70 | WITH ${fragmentsSQL}
71 | ${dedent(sql)}
72 | `,
73 | params: [...fragmentsParams, ...params],
74 | };
75 | };
76 |
--------------------------------------------------------------------------------
/web/src/components/GithubButtons.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, useColorModeValue } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { BsGithub } from "react-icons/bs";
4 |
5 | export const GitStargazerLogo = () => {
6 | return (
7 |
19 |
20 | star
21 |
22 | );
23 | };
24 | export interface GithubStargazerProps {
25 | owner: string;
26 | repoName: string;
27 | color?: string;
28 | }
29 |
30 | export const GithubStargazer: React.FC = ({
31 | owner,
32 | repoName,
33 | }) => {
34 | const [stargazersCount, setStargazersCount] = React.useState(0);
35 |
36 | const handleFlexRedirect = () => {
37 | window.open(`https://github.com/${owner}/${repoName}`, "_blank");
38 | };
39 |
40 | const getStarCount = async () => {
41 | const res = await fetch(
42 | `https://api.github.com/repos/${owner}/${repoName}`
43 | );
44 | const resJson = await res.json();
45 | setStargazersCount(resJson.stargazers_count);
46 | };
47 | getStarCount();
48 |
49 | return (
50 |
58 |
59 |
71 | {stargazersCount}
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import { basename } from "path";
3 | import { defineConfig } from "vite";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [
9 | tsconfigPaths(),
10 | react({
11 | babel: {
12 | plugins: ["@emotion/babel-plugin"],
13 | },
14 | }),
15 | transformSQL(),
16 | ],
17 | });
18 |
19 | function transformSQL() {
20 | const sqlRegex = /\.(sql)$/;
21 |
22 | const parseStatement = (
23 | raw: string
24 | ): { kind: string; name?: string; statement: string } => {
25 | const statement = raw.trim();
26 | const kind = statement.split(" ")[0].trim().toLowerCase();
27 | const name = statement.match(/^CREATE.+?(?\w+)(as| )*\(?\n/i)?.groups
28 | ?.name;
29 | if (name) {
30 | return { kind, name, statement };
31 | }
32 | return { kind, statement };
33 | };
34 |
35 | const parseStatements = (raw: string) =>
36 | raw
37 | .split(/(?<=;)/)
38 | .filter((s) => !!s.trim())
39 | .map(parseStatement);
40 |
41 | const parseProcedures = (raw: string) =>
42 | raw
43 | .replace(/DELIMITER (\/\/|;)/g, "")
44 | .split(/(?<=END \/\/)/)
45 | .filter((s) => !!s.trim())
46 | .map((s) => s.replace("END //", "END"))
47 | .map(parseStatement);
48 |
49 | const render = (data) => ({
50 | code: `export default ${JSON.stringify(data)};`,
51 | map: null,
52 | });
53 |
54 | return {
55 | name: "transform-sql",
56 |
57 | transform(src: string, id: string) {
58 | if (sqlRegex.test(id)) {
59 | switch (basename(id)) {
60 | case "schema.sql":
61 | return render(parseStatements(src));
62 |
63 | case "seed.sql":
64 | return render(parseStatements(src));
65 |
66 | case "pipelines.sql":
67 | return render(parseStatements(src));
68 |
69 | case "functions.sql":
70 | return render(parseProcedures(src));
71 |
72 | case "procedures.sql":
73 | return render(parseProcedures(src));
74 | }
75 | }
76 | },
77 | };
78 | }
79 |
--------------------------------------------------------------------------------
/web/src/components/dataConfigForm/DatabaseConfigFormAutomatic.tsx:
--------------------------------------------------------------------------------
1 | import { SimpleGrid, Stack } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { useRecoilState } from "recoil";
4 |
5 | import { ConfigInput } from "@/components/ConfigInput";
6 | import { ScaleFactorSelector } from "@/components/ScaleFactorSelector";
7 | import {
8 | connectionDatabase,
9 | connectionHost,
10 | connectionPassword,
11 | connectionUser,
12 | } from "@/data/recoil";
13 |
14 | type Props = {
15 | showDatabase?: boolean;
16 | showScaleFactor?: boolean;
17 | };
18 |
19 | export const DatabaseConfigForm = ({
20 | showDatabase,
21 | showScaleFactor,
22 | }: Props) => {
23 | const [host, setHost] = useRecoilState(connectionHost);
24 | const [user, setUser] = useRecoilState(connectionUser);
25 | const [password, setPassword] = useRecoilState(connectionPassword);
26 | const [database, setDatabase] = useRecoilState(connectionDatabase);
27 |
28 | let databaseInput;
29 | if (showDatabase) {
30 | databaseInput = (
31 |
37 | );
38 | }
39 |
40 | let scaleFactor;
41 | if (showScaleFactor) {
42 | scaleFactor = ;
43 | }
44 |
45 | return (
46 |
47 |
54 |
55 |
62 |
69 |
70 | {databaseInput}
71 | {scaleFactor}
72 |
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/web/src/components/IconLinks.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconButton,
3 | IconButtonProps,
4 | useColorModeValue,
5 | } from "@chakra-ui/react";
6 | import * as React from "react";
7 | import { BsLinkedin } from "react-icons/bs";
8 | import { VscGithub, VscGithubInverted, VscTwitter } from "react-icons/vsc";
9 |
10 | type customeIconProps = Omit<
11 | IconButtonProps,
12 | "Background" | "icon" | "value" | "aria-label"
13 | >;
14 |
15 | export const TwitterIconButton = (props: customeIconProps) => {
16 | const url = `https://digital-marketing.labs.singlestore.com/`;
17 | const text = `Exciting MarTech demo application from SingleStoreDB showcasing its unique capabilities! As a demo app, it gives you a taste of what's possible when using SingleStoreDB for your own projects. \
18 | #SingleStoreDB #database #digitalmarketing #appdevelopment `;
19 |
20 | return (
21 | }
25 | value={`https://twitter.com/intent/tweet?url=${encodeURIComponent(
26 | url
27 | )}&text=${encodeURIComponent(text)}`}
28 | {...props}
29 | />
30 | );
31 | };
32 |
33 | export const LinkedinIconButton = (props: customeIconProps) => {
34 | const url = "https://digital-marketing.labs.singlestore.com/";
35 |
36 | return (
37 | }
41 | value={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(
42 | url
43 | )}`}
44 | {...props}
45 | />
46 | );
47 | };
48 |
49 | export const GithubIconButton = (props: customeIconProps) => {
50 | const url =
51 | "https://github.com/singlestore-labs/demo-realtime-digital-marketing";
52 | const gitHubIconButton = useColorModeValue(
53 | ,
54 |
55 | );
56 |
57 | return (
58 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/gen/fill.go:
--------------------------------------------------------------------------------
1 | package gen
2 |
3 | import (
4 | "subscriber-sim/output"
5 | )
6 |
7 | type Batch struct {
8 | partitionId int
9 | seqId int64
10 | locations []output.Location
11 | requests []output.Request
12 | purchases []output.Purchase
13 | }
14 |
15 | func NewBatch(state *State) *Batch {
16 | return &Batch{
17 | partitionId: state.PartitionId,
18 | seqId: state.SeqId,
19 | locations: make([]output.Location, len(state.Subscribers)),
20 | requests: make([]output.Request, len(state.Subscribers)),
21 | purchases: make([]output.Purchase, len(state.Subscribers)),
22 | }
23 | }
24 |
25 | func (b *Batch) PartitionId() int {
26 | return b.partitionId
27 | }
28 |
29 | func (b *Batch) SeqId() int64 {
30 | return b.seqId
31 | }
32 |
33 | func (b *Batch) Locations() []output.Location {
34 | return b.locations
35 | }
36 |
37 | func (b *Batch) Requests() []output.Request {
38 | return b.requests
39 | }
40 |
41 | func (b *Batch) Purchases() []output.Purchase {
42 | return b.purchases
43 | }
44 |
45 | func FillBatch(state *State, batch *Batch) {
46 | // we need to scale up the slices here since we don't know at this point how
47 | // many requests/purchases there are in the batch
48 | batch.requests = batch.requests[:len(state.Subscribers)]
49 | batch.purchases = batch.purchases[:len(state.Subscribers)]
50 |
51 | // update the sequence Id for this batch
52 | batch.seqId = state.SeqId
53 |
54 | numRequests := 0
55 | numPurchases := 0
56 |
57 | for i := range state.Subscribers {
58 | subscriber := &state.Subscribers[i]
59 |
60 | batch.locations[i].SubscriberId = subscriber.Id
61 | batch.locations[i].Offset[0] = subscriber.Location[0]
62 | batch.locations[i].Offset[1] = subscriber.Location[1]
63 |
64 | if subscriber.LastRequestDomain != "" {
65 | batch.requests[numRequests].SubscriberId = subscriber.Id
66 | batch.requests[numRequests].Domain = subscriber.LastRequestDomain
67 | numRequests++
68 | }
69 |
70 | if subscriber.LastPurchaseVendor != "" {
71 | batch.purchases[numPurchases].SubscriberId = subscriber.Id
72 | batch.purchases[numPurchases].Vendor = subscriber.LastPurchaseVendor
73 | numPurchases++
74 | }
75 | }
76 |
77 | batch.requests = batch.requests[:numRequests]
78 | batch.purchases = batch.purchases[:numPurchases]
79 | }
80 |
--------------------------------------------------------------------------------
/web/src/components/Stats.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | SimpleGrid,
3 | Stat,
4 | StatLabel,
5 | StatNumber,
6 | useColorModeValue,
7 | } from "@chakra-ui/react";
8 | import { format } from "d3-format";
9 | import * as React from "react";
10 | import { useRecoilValue } from "recoil";
11 | import useSWR from "swr";
12 |
13 | import { estimatedRowCountObj } from "@/data/queries";
14 | import { connectionConfig, tickDurationMs } from "@/data/recoil";
15 | import { formatMs } from "@/format";
16 |
17 | import { Loader } from "./customcomponents/loader/Loader";
18 |
19 | const StatWrapper = ({
20 | statLabel,
21 | statNumber,
22 | }: {
23 | statLabel: string;
24 | statNumber: string;
25 | }) => {
26 | return (
27 |
32 | {statLabel}
33 |
34 | {statNumber}
35 |
36 |
37 | );
38 | };
39 |
40 | export const Stats = () => {
41 | const config = useRecoilValue(connectionConfig);
42 | const matchingDuration = useRecoilValue(tickDurationMs("SimulatorMatcher"));
43 | const updateSegmentsDuration = useRecoilValue(
44 | tickDurationMs("SimulatorUpdateSegments")
45 | );
46 | const tableCounts = useSWR(
47 | ["notificationsMapTableCounts", config],
48 | () =>
49 | estimatedRowCountObj(
50 | config,
51 | "offers",
52 | "subscribers",
53 | "cities",
54 | "segments"
55 | ),
56 | { refreshInterval: 1000 }
57 | );
58 | const formatStat = format(".4~s");
59 |
60 | if (!tableCounts.data) {
61 | return ;
62 | }
63 |
64 | return (
65 |
66 |
70 |
74 |
78 |
82 |
86 |
90 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/web/src/components/theme.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ColorMode,
3 | extendTheme,
4 | Theme,
5 | theme as origTheme,
6 | } from "@chakra-ui/react";
7 |
8 | import "@fontsource/inter/variable-full.css";
9 | import "@fontsource/source-code-pro/variable.css";
10 |
11 | export const chakraTheme = extendTheme({
12 | colors: {
13 | indigo: {
14 | 50: "#F7F6FE",
15 | 100: "#ECE8FD",
16 | 200: "#DCD5FB",
17 | 300: "#CCC3F9",
18 | 400: "#B0A0F8",
19 | 500: "#7760E1",
20 | 600: "#553ACF",
21 | 700: "#472EB7",
22 | 800: "#3A249E",
23 | 900: "#2F206E",
24 | },
25 | },
26 | fonts: {
27 | heading: "InterVariable, sans-serif",
28 | body: "InterVariable, sans-serif",
29 | mono: '"Source Code ProVariable", monospace',
30 | },
31 | styles: {
32 | global: ({ colorMode }: { colorMode: ColorMode }) => ({
33 | a: {
34 | color: colorMode === "light" ? "indigo.600" : "indigo.300",
35 | },
36 | }),
37 | },
38 | components: {
39 | Link: {
40 | baseStyle: ({ colorMode }: { colorMode: ColorMode }) => ({
41 | color: colorMode === "light" ? "indigo.600" : "indigo.300",
42 | }),
43 | },
44 | Button: {
45 | variants: {
46 | solid: {
47 | _focus: {
48 | border: 0,
49 | "box-shadow": "0 0 0 3px currentColor",
50 | },
51 | },
52 | },
53 | },
54 | Alert: {
55 | variants: {
56 | solid: (props: {
57 | colorScheme: string;
58 | colorMode: "light" | "dark";
59 | theme: Theme;
60 | }) => {
61 | // only applies to `solid` variant
62 | const { colorScheme: c, colorMode } = props;
63 | if (c !== "blue") {
64 | // use original definition for all color schemes except "blue"
65 | return origTheme.components.Alert.variants.solid(props);
66 | }
67 | return {
68 | container: {
69 | bg: colorMode === "light" ? "indigo.600" : "indigo.300",
70 | },
71 | };
72 | },
73 | },
74 | },
75 | Switch: {
76 | variants: {
77 | simulator: ({ colorMode }: { colorMode: ColorMode }) => ({
78 | track: {
79 | _checked: {
80 | bg: colorMode === "light" ? "black" : "white",
81 | },
82 | },
83 | thumb: {
84 | bg: colorMode === "light" ? "white" : "black",
85 | },
86 | }),
87 | },
88 | },
89 | Tooltip: {
90 | variants: {
91 | simulator: ({ colorMode }: { colorMode: ColorMode }) => ({
92 | bg: colorMode === "light" ? "#171923" : "#F3F3F5",
93 | }),
94 | },
95 | },
96 | },
97 | });
98 |
--------------------------------------------------------------------------------
/web/src/assets/singlestore-logo-holo.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.0.0",
4 | "license": "Apache-2.0",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "preview": "vite preview",
9 | "prettier": "prettier --write 'src/**/*.{tsx,ts}'",
10 | "lint": "eslint --ext .ts,.tsx src/"
11 | },
12 | "dependencies": {
13 | "@chakra-ui/icons": "^1.1.1",
14 | "@chakra-ui/react": "^1.7.4",
15 | "@chakra-ui/system": "^1.10.1",
16 | "@emotion/react": "^11",
17 | "@emotion/styled": "^11",
18 | "@fontsource/inter": "^4.5.1",
19 | "@fontsource/source-code-pro": "^4.5.1",
20 | "@pixi/graphics-extras": "^6.2.2",
21 | "@visx/xychart": "^2.9.0",
22 | "chakra-ui-markdown-renderer": "^4.0.0",
23 | "d3-color": "^3.0.1",
24 | "d3-ease": "^3.0.1",
25 | "d3-format": "^3.0.1",
26 | "d3-scale": "^4.0.2",
27 | "d3-interpolate": "3.0.1",
28 | "framer-motion": "^5",
29 | "open-location-code-typescript": "^1.5.0",
30 | "pigeon-maps": "0.20",
31 | "pixi.js": "6.2.2",
32 | "react": "^17.0.2",
33 | "react-dom": "^17.0.2",
34 | "react-icons": "^4.3.1",
35 | "react-markdown": "^8.0.0",
36 | "react-router-dom": "6",
37 | "react-select": "5.7.0",
38 | "react-spring": "^9.4.3",
39 | "recoil": "^0.6",
40 | "sass": "1.58.0",
41 | "string-hash": "^1.1.3",
42 | "swr": "^1.1.2",
43 | "ts-dedent": "^2.2.0",
44 | "uuid": "^8.3.2"
45 | },
46 | "resolutions": {
47 | "csstype": "3.0.10"
48 | },
49 | "devDependencies": {
50 | "@babel/core": "^7.16.12",
51 | "@chakra-ui/cli": "^1.8.1",
52 | "@emotion/babel-plugin": "^11.7.2",
53 | "@types/d3-array": "3.0.1",
54 | "@types/d3-color": "3.0.1",
55 | "@types/d3-ease": "3.0.0",
56 | "@types/d3-format": "3.0.1",
57 | "@types/d3-scale": "4.0.2",
58 | "@types/d3-interpolate": "3.0.1",
59 | "@types/node": "^18.0.6",
60 | "@types/offscreencanvas": "^2019.6.4",
61 | "@types/react": "^17.0.33",
62 | "@types/react-dom": "^17.0.10",
63 | "@types/segment-analytics": "0.0.34",
64 | "@types/string-hash": "^1.1.1",
65 | "@types/uuid": "^8.3.4",
66 | "@typescript-eslint/eslint-plugin": "^5.10.1",
67 | "@typescript-eslint/parser": "^5.10.1",
68 | "@vitejs/plugin-react": "^1.0.7",
69 | "autoprefixer": "^10.4.2",
70 | "babel-eslint": "10.1.0",
71 | "eslint": "8.32.0",
72 | "eslint-plugin-babel": "5.3.1",
73 | "eslint-plugin-import": "2.27.5",
74 | "eslint-plugin-react": "7.32.2",
75 | "eslint-plugin-react-hooks": "4.3.0",
76 | "eslint-plugin-simple-import-sort": "10.0.0",
77 | "eslint-plugin-switch-case": "1.1.2",
78 | "eslint-plugin-unused-imports": "2.0.0",
79 | "@rushstack/eslint-config": "3.2.0",
80 | "@singlestore/eslint-plugin-react-hooks-disable-import": "1.0.0",
81 | "postcss": "^8.4.5",
82 | "prettier": "^2.7.1",
83 | "typescript": "^4.4.4",
84 | "vite": "^2.9.13",
85 | "vite-tsconfig-paths": "^3.3.17"
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module subscriber-sim
2 |
3 | go 1.17
4 |
5 | require (
6 | github.com/fraugster/parquet-go v0.10.0
7 | github.com/ungerik/go3d v0.0.0-20220112162149-77a75dbba58f
8 | gocloud.dev v0.24.0
9 | )
10 |
11 | require (
12 | cloud.google.com/go v0.100.2 // indirect
13 | cloud.google.com/go/compute v1.5.0 // indirect
14 | cloud.google.com/go/iam v0.2.0 // indirect
15 | cloud.google.com/go/storage v1.21.0 // indirect
16 | github.com/Azure/azure-pipeline-go v0.2.3 // indirect
17 | github.com/Azure/azure-storage-blob-go v0.14.0 // indirect
18 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect
19 | github.com/Azure/go-autorest/autorest v0.11.24 // indirect
20 | github.com/Azure/go-autorest/autorest/adal v0.9.18 // indirect
21 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
22 | github.com/Azure/go-autorest/logger v0.2.1 // indirect
23 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect
24 | github.com/apache/thrift v0.16.0 // indirect
25 | github.com/aws/aws-sdk-go v1.43.9 // indirect
26 | github.com/aws/aws-sdk-go-v2 v1.14.0 // indirect
27 | github.com/aws/aws-sdk-go-v2/config v1.14.0 // indirect
28 | github.com/aws/aws-sdk-go-v2/credentials v1.9.0 // indirect
29 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.11.0 // indirect
30 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.5 // indirect
31 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.3.0 // indirect
32 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.6 // indirect
33 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.8.0 // indirect
34 | github.com/aws/aws-sdk-go-v2/service/sso v1.10.0 // indirect
35 | github.com/aws/aws-sdk-go-v2/service/sts v1.15.0 // indirect
36 | github.com/aws/smithy-go v1.11.0 // indirect
37 | github.com/golang-jwt/jwt/v4 v4.3.0 // indirect
38 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
39 | github.com/golang/protobuf v1.5.2 // indirect
40 | github.com/golang/snappy v0.0.4 // indirect
41 | github.com/google/go-cmp v0.5.7 // indirect
42 | github.com/google/uuid v1.3.0 // indirect
43 | github.com/google/wire v0.5.0 // indirect
44 | github.com/googleapis/gax-go/v2 v2.1.1 // indirect
45 | github.com/jmespath/go-jmespath v0.4.0 // indirect
46 | github.com/mattn/go-ieproxy v0.0.3 // indirect
47 | github.com/pkg/errors v0.9.1 // indirect
48 | go.opencensus.io v0.23.0 // indirect
49 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect
50 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
51 | golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
52 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect
53 | golang.org/x/text v0.3.7 // indirect
54 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
55 | google.golang.org/api v0.70.0 // indirect
56 | google.golang.org/appengine v1.6.7 // indirect
57 | google.golang.org/genproto v0.0.0-20220301145929-1ac2ace0dbf7 // indirect
58 | google.golang.org/grpc v1.44.0 // indirect
59 | google.golang.org/protobuf v1.27.1 // indirect
60 | )
61 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
11 |
12 |
13 |
14 |
16 |
17 |
18 |
19 | SingleStore: Real-Time Digital Marketing Demo
20 |
21 |
22 |
23 |
24 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/output/parquet.go:
--------------------------------------------------------------------------------
1 | package output
2 |
3 | import (
4 | "io"
5 |
6 | goparquet "github.com/fraugster/parquet-go"
7 | "github.com/fraugster/parquet-go/parquet"
8 | "github.com/fraugster/parquet-go/parquetschema"
9 | )
10 |
11 | func mustCreateSchemaDefinition(s string) *parquetschema.SchemaDefinition {
12 | schema, err := parquetschema.ParseSchemaDefinition(s)
13 | if err != nil {
14 | panic(err)
15 | }
16 | return schema
17 | }
18 |
19 | var (
20 | parquetBaseOptions = []goparquet.FileWriterOption{
21 | goparquet.WithCompressionCodec(parquet.CompressionCodec_SNAPPY),
22 | goparquet.WithDataPageV2(),
23 | }
24 |
25 | parquetLocationOptions = append(parquetBaseOptions,
26 | goparquet.WithSchemaDefinition(mustCreateSchemaDefinition(`
27 | message location {
28 | required int64 seqid;
29 | required int64 subscriberid;
30 | required double offsetX;
31 | required double offsetY;
32 | }
33 | `)),
34 | )
35 |
36 | parquetRequestOptions = append(parquetBaseOptions,
37 | goparquet.WithSchemaDefinition(mustCreateSchemaDefinition(`
38 | message request {
39 | required int64 seqid;
40 | required int64 subscriberid;
41 | required binary domain (STRING);
42 | }
43 | `)),
44 | )
45 |
46 | parquetPurchaseOptions = append(parquetBaseOptions,
47 | goparquet.WithSchemaDefinition(mustCreateSchemaDefinition(`
48 | message purchase {
49 | required int64 seqid;
50 | required int64 subscriberid;
51 | required binary vendor (STRING);
52 | }
53 | `)),
54 | )
55 | )
56 |
57 | type ParquetEncoder struct {
58 | }
59 |
60 | func (e *ParquetEncoder) Extension() string {
61 | return "parquet"
62 | }
63 |
64 | func (e *ParquetEncoder) EncodeLocations(seqId int64, rows []Location, w io.Writer) error {
65 | fw := goparquet.NewFileWriter(w, parquetLocationOptions...)
66 | obj := map[string]interface{}{
67 | "seqid": nil,
68 | "subscriberid": nil,
69 | "offsetX": nil,
70 | "offsetY": nil,
71 | }
72 |
73 | for i := range rows {
74 | obj["seqid"] = seqId
75 | obj["subscriberid"] = rows[i].SubscriberId
76 | obj["offsetX"] = rows[i].Offset[0]
77 | obj["offsetY"] = rows[i].Offset[1]
78 |
79 | if err := fw.AddData(obj); err != nil {
80 | return err
81 | }
82 | }
83 |
84 | return fw.Close()
85 | }
86 |
87 | func (e *ParquetEncoder) EncodeRequests(seqId int64, rows []Request, w io.Writer) error {
88 | fw := goparquet.NewFileWriter(w, parquetRequestOptions...)
89 | obj := map[string]interface{}{
90 | "seqid": nil,
91 | "subscriberid": nil,
92 | "domain": nil,
93 | }
94 |
95 | for i := range rows {
96 | obj["seqid"] = seqId
97 | obj["subscriberid"] = rows[i].SubscriberId
98 | obj["domain"] = []byte(rows[i].Domain)
99 |
100 | if err := fw.AddData(obj); err != nil {
101 | return err
102 | }
103 | }
104 |
105 | return fw.Close()
106 | }
107 |
108 | func (e *ParquetEncoder) EncodePurchases(seqId int64, rows []Purchase, w io.Writer) error {
109 | fw := goparquet.NewFileWriter(w, parquetPurchaseOptions...)
110 | obj := map[string]interface{}{
111 | "seqid": nil,
112 | "subscriberid": nil,
113 | "vendor": nil,
114 | }
115 |
116 | for i := range rows {
117 | obj["seqid"] = seqId
118 | obj["subscriberid"] = rows[i].SubscriberId
119 | obj["vendor"] = []byte(rows[i].Vendor)
120 |
121 | if err := fw.AddData(obj); err != nil {
122 | return err
123 | }
124 | }
125 |
126 | return fw.Close()
127 | }
128 |
--------------------------------------------------------------------------------
/cmd/simulator/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "log"
7 | "subscriber-sim/gen"
8 | "subscriber-sim/output"
9 | "subscriber-sim/util"
10 | "sync"
11 | "time"
12 |
13 | "gocloud.dev/blob"
14 |
15 | _ "gocloud.dev/blob/azureblob"
16 | _ "gocloud.dev/blob/fileblob"
17 | _ "gocloud.dev/blob/gcsblob"
18 | _ "gocloud.dev/blob/s3blob"
19 | )
20 |
21 | var (
22 | blobURL = flag.String("blob", "file:///tmp/subscriber-sim?metadata=skip", "blob URL (see https://gocloud.dev/howto/blob/#services for url syntax)")
23 | seed = flag.Int64("seed", time.Now().UnixNano(), "set the seed")
24 | format = flag.String("format", "json", "output format (json, parquet)")
25 |
26 | numPartitions = flag.Int("partitions", 1, "number of partitions")
27 | numSubscribers = flag.Int("subscribers", 100_000, "number of subscribers")
28 |
29 | purchaseProb = flag.Float64("purchase-prob", 0.05, "purchase probability")
30 | requestProb = flag.Float64("request-prob", 0.3, "request probability")
31 |
32 | minSpeed = flag.Float64("min-speed", 0.001, "minimum speed")
33 | maxSpeed = flag.Float64("max-speed", 0.01, "maximum speed")
34 |
35 | iterations = flag.Int("iterations", 10, "number of iterations")
36 | progressFreq = flag.Int("progress-freq", 10, "frequency of progress reports (0 disables)")
37 | )
38 |
39 | func main() {
40 | flag.Parse()
41 |
42 | var batchEncoder output.BatchEncoder
43 | switch *format {
44 | case "json":
45 | batchEncoder = &output.JSONEncoder{}
46 | case "parquet":
47 | batchEncoder = &output.ParquetEncoder{}
48 | default:
49 | log.Fatalf("unknown format: '%s'", *format)
50 | }
51 |
52 | if *numPartitions < 1 {
53 | log.Fatalf("invalid number of partitions: %d", *numPartitions)
54 | }
55 | if *numSubscribers < 1 {
56 | log.Fatalf("invalid number of subscribers: %d", *numSubscribers)
57 | }
58 |
59 | rnd := util.NewRandGen(*seed)
60 | ctx := context.Background()
61 |
62 | b, err := blob.OpenBucket(ctx, *blobURL)
63 | if err != nil {
64 | log.Fatal(err)
65 | }
66 | defer b.Close()
67 |
68 | bw := output.NewBlobWriter(b, batchEncoder)
69 |
70 | subsPerPartition := *numSubscribers / *numPartitions
71 |
72 | wg := &sync.WaitGroup{}
73 |
74 | startTime := time.Now()
75 |
76 | for i := 0; i < *numPartitions; i++ {
77 | wg.Add(1)
78 |
79 | go func(partitionid int) {
80 | defer wg.Done()
81 |
82 | genExt := output.NewExtensionGenerator()
83 |
84 | state := &gen.State{
85 | PartitionId: partitionid,
86 | Rand: rnd.Next(),
87 |
88 | PurchaseProb: *purchaseProb,
89 | RequestProb: *requestProb,
90 |
91 | MinSpeed: *minSpeed,
92 | MaxSpeed: *maxSpeed,
93 | }
94 | gen.InitSubscribers(state, subsPerPartition)
95 |
96 | batch := gen.NewBatch(state)
97 |
98 | for i := 0; i < *iterations; i++ {
99 | // SeqId is generated per batch per partition
100 | // we use SeqId when we load data to calculate all of our timestamps
101 | state.SeqId++
102 |
103 | gen.UpdateSubscribers(state)
104 | gen.FillBatch(state, batch)
105 |
106 | err = bw.Write(ctx, batch, genExt)
107 | if err != nil {
108 | log.Fatal(err)
109 | }
110 |
111 | if *progressFreq > 0 && (i+1)%(*progressFreq) == 0 {
112 | log.Printf("partition %d: %d/%d complete", partitionid, i+1, *iterations)
113 | }
114 | }
115 | }(i)
116 | }
117 |
118 | wg.Wait()
119 |
120 | duration := time.Since(startTime)
121 |
122 | log.Printf("finished in %s", duration)
123 | log.Printf("%.2f batches per second", float64(*iterations**numPartitions)/duration.Seconds())
124 | }
125 |
--------------------------------------------------------------------------------
/web/src/components/ErrorHandler.tsx:
--------------------------------------------------------------------------------
1 | import { RepeatIcon, WarningTwoIcon } from "@chakra-ui/icons";
2 | import {
3 | Button,
4 | Center,
5 | Container,
6 | Heading,
7 | HStack,
8 | Stack,
9 | Text,
10 | } from "@chakra-ui/react";
11 | import * as React from "react";
12 | import { ReactNode } from "react-markdown/lib/react-markdown";
13 | import { useRecoilValue } from "recoil";
14 | import dedent from "ts-dedent";
15 |
16 | import { CodeBlock } from "@/components/CodeBlock";
17 | import { SQLError } from "@/data/client";
18 | import { resettingSchema } from "@/data/recoil";
19 |
20 | import { PrimaryButton } from "./customcomponents/Button";
21 |
22 | type Props = {
23 | isResettingSchema?: boolean;
24 | children?: ReactNode;
25 | };
26 |
27 | type State = {
28 | error?: Error;
29 | };
30 |
31 | export const ClientErrorBoundary = ({ children }: { children: ReactNode }) => {
32 | const isResettingSchema = useRecoilValue(resettingSchema);
33 | return (
34 |
35 | {children}
36 |
37 | );
38 | };
39 |
40 | export class ErrorBoundary extends React.Component {
41 | state: State = {};
42 |
43 | constructor(props: Props) {
44 | super(props);
45 | this.handlePromiseRejection = this.handlePromiseRejection.bind(this);
46 | }
47 |
48 | componentDidMount() {
49 | window.addEventListener("unhandledrejection", this.handlePromiseRejection);
50 | }
51 |
52 | componentWillUnmount() {
53 | window.removeEventListener(
54 | "unhandledrejection",
55 | this.handlePromiseRejection
56 | );
57 | }
58 |
59 | handlePromiseRejection(ev: PromiseRejectionEvent) {
60 | if (this.props.isResettingSchema) {
61 | console.warn("Ignoring error while resetting schema", ev.reason);
62 | } else {
63 | this.setState({ error: ev.reason });
64 | }
65 | }
66 |
67 | componentDidCatch(error: Error) {
68 | if (this.props.isResettingSchema) {
69 | console.warn("Ignoring error while resetting schema", error);
70 | } else {
71 | this.setState({ error });
72 | }
73 | }
74 |
75 | render() {
76 | const { error } = this.state;
77 | if (error) {
78 | let info;
79 | if (error instanceof SQLError) {
80 | info = (
81 | <>
82 |
83 | An error occurred while running the following query:
84 |
85 | {dedent(error.sql)}
86 | >
87 | );
88 | }
89 |
90 | return (
91 |
92 |
93 |
94 |
95 |
96 |
97 | {error.message}
98 |
99 | {info}
100 |
101 |
107 | window.location.reload()}
109 | size="sm"
110 | leftIcon={}
111 | >
112 | Reload
113 |
114 |
115 |
116 |
117 | );
118 | }
119 |
120 | return <>{this.props.children}>;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/web/src/components/ResetSchemaButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogBody,
4 | AlertDialogContent,
5 | AlertDialogFooter,
6 | AlertDialogHeader,
7 | AlertDialogOverlay,
8 | Button,
9 | ButtonOptions,
10 | HTMLChakraProps,
11 | ThemingProps,
12 | useBoolean,
13 | useColorModeValue,
14 | useDisclosure,
15 | } from "@chakra-ui/react";
16 | import * as React from "react";
17 | import { useRecoilValue } from "recoil";
18 |
19 | import { Loader } from "@/components/customcomponents/loader/Loader";
20 | import { useUpdateCityList } from "@/data/models/useUpdateCityList";
21 | import { connectionDatabase } from "@/data/recoil";
22 | import {
23 | useConnectionState,
24 | useMountedCallback,
25 | useResetSchema,
26 | } from "@/view/hooks/hooks";
27 |
28 | import { PrimaryButton } from "./customcomponents/Button";
29 |
30 | export type Props = HTMLChakraProps<"button"> &
31 | ButtonOptions &
32 | ThemingProps<"Button"> & {
33 | skipSeedData?: boolean;
34 | resetDataOnly?: boolean;
35 | };
36 |
37 | export const ResetSchemaButton = (props: Props) => {
38 | const { updateCityList } = useUpdateCityList();
39 | const { connected, initialized } = useConnectionState();
40 | const { onOpen, onClose, isOpen } = useDisclosure();
41 | const [resettingSchema, resettingSchemaCtrl] = useBoolean();
42 | const database = useRecoilValue(connectionDatabase);
43 | const cancelResetSchemaBtn = React.useRef(null);
44 | const { skipSeedData, resetDataOnly, disabled, ...restProps } = props;
45 |
46 | let resetButtonContent: React.ReactNode = "Create Database";
47 | if (resettingSchema) {
48 | resetButtonContent = ;
49 | } else if (initialized) {
50 | resetButtonContent = "Recreate database";
51 | }
52 |
53 | const onResetSchema = useResetSchema({
54 | before: React.useCallback(
55 | () => resettingSchemaCtrl.on(),
56 | [resettingSchemaCtrl]
57 | ),
58 | after: useMountedCallback(() => {
59 | resettingSchemaCtrl.off();
60 | updateCityList();
61 | onClose();
62 | }, [onClose, resettingSchemaCtrl]),
63 | includeSeedData: !skipSeedData,
64 | resetDataOnly: !!resetDataOnly,
65 | });
66 |
67 | return (
68 | <>
69 |
74 |
75 |
82 |
83 |
84 |
85 | {initialized ? "Reset" : "Setup"} database {database}
86 |
87 |
88 | This will {initialized ? "recreate" : "create"} the database
89 | called {database}. Are you sure?
90 |
91 |
92 |
102 |
107 | {resetButtonContent}
108 |
109 |
110 |
111 |
112 |
113 | >
114 | );
115 | };
116 |
--------------------------------------------------------------------------------
/web/src/pages/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Flex,
3 | Grid,
4 | GridItem,
5 | Heading,
6 | Image,
7 | Link,
8 | Stack,
9 | Tab,
10 | TabList,
11 | TabPanel,
12 | TabPanels,
13 | Tabs,
14 | Text,
15 | useColorModeValue,
16 | useMediaQuery,
17 | } from "@chakra-ui/react";
18 | import * as React from "react";
19 | import { useNavigate } from "react-router-dom";
20 |
21 | import DashboardControllerImage from "@/assets/dashboard-controller-snapshot.svg";
22 | import GraphicalBackground2 from "@/assets/graphical-background-2.svg";
23 | import SingleStoreLogoDark from "@/assets/singlestore-logo-dark.svg";
24 | import SinglestoreLogo from "@/assets/singlestore-logo-filled-sm.svg";
25 | import { DatabaseConfigFormManual } from "@/components/dataConfigForm/DatabaseConfigFormManual";
26 | import { NeedHelpModal } from "@/components/NeedHelpModal";
27 | import { useUpdateCityList } from "@/data/models/useUpdateCityList";
28 | import { useConnectionState } from "@/view/hooks/hooks";
29 |
30 | const ConnectSection: React.FC = () => {
31 | const fontColor = useColorModeValue("#553ACF", "#CCC3F9");
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 | Real-Time Digital Marketing
40 |
41 |
42 | Watch{" "}
43 |
48 | SingleStoreDB
49 | {" "}
50 | serve ads to millions of simulated subscribers based on their behavior,
51 | purchases, request history, and geolocation.
52 |
53 |
54 |
55 |
67 | Connect to SinglestoreDB
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | );
80 | };
81 |
82 | export const HomePage: React.FC = () => {
83 | const { connected } = useConnectionState();
84 | const navigate = useNavigate();
85 | const [isSmallScreen] = useMediaQuery("(max-width: 640px)");
86 | const { updateCityList } = useUpdateCityList();
87 |
88 | React.useEffect(() => {
89 | if (connected) {
90 | updateCityList();
91 | navigate("/dashboard");
92 | }
93 | }, [connected, navigate, updateCityList]);
94 |
95 | return (
96 |
103 |
104 |
105 |
106 |
117 |
123 |
124 |
125 |
126 |
127 | );
128 | };
129 |
--------------------------------------------------------------------------------
/sql/procedures.sql:
--------------------------------------------------------------------------------
1 | DELIMITER //
2 |
3 | CREATE OR REPLACE PROCEDURE process_locations (
4 | _batch QUERY(
5 | subscriber_id BIGINT NOT NULL,
6 | offset_x DOUBLE NOT NULL,
7 | offset_y DOUBLE NOT NULL
8 | )
9 | )
10 | AS
11 | DECLARE
12 | _expanded QUERY(city_id BIGINT, subscriber_id BIGINT, lonlat GEOGRAPHYPOINT) = SELECT
13 | city_id, subscriber_id,
14 | GEOGRAPHY_POINT(
15 | GEOGRAPHY_LONGITUDE(center) + (offset_x * diameter),
16 | GEOGRAPHY_LATITUDE(center) + (offset_y * diameter)
17 | ) AS lonlat
18 | FROM _batch, cities;
19 | BEGIN
20 | INSERT INTO subscribers (city_id, subscriber_id, current_location)
21 | SELECT city_id, subscriber_id, lonlat
22 | FROM _expanded
23 | ON DUPLICATE KEY UPDATE current_location = VALUES(current_location);
24 |
25 | INSERT INTO locations (city_id, subscriber_id, ts, lonlat, olc_8)
26 | SELECT
27 | city_id,
28 | subscriber_id,
29 | now(6) AS ts,
30 | lonlat,
31 | encode_open_location_code(lonlat, 8) AS olc_8
32 | FROM _expanded;
33 | END //
34 |
35 | CREATE OR REPLACE PROCEDURE process_requests (
36 | _batch QUERY(subscriber_id BIGINT NOT NULL, domain TEXT NOT NULL)
37 | )
38 | AS
39 | BEGIN
40 | INSERT INTO requests (city_id, subscriber_id, ts, domain)
41 | SELECT city_id, subscriber_id, now(6) AS ts, domain
42 | FROM _batch, cities;
43 | END //
44 |
45 | CREATE OR REPLACE PROCEDURE process_purchases (
46 | _batch QUERY(subscriber_id BIGINT NOT NULL, vendor TEXT NOT NULL)
47 | )
48 | AS
49 | BEGIN
50 | INSERT INTO purchases (city_id, subscriber_id, ts, vendor)
51 | SELECT city_id, subscriber_id, now(6) AS ts, vendor
52 | FROM _batch, cities;
53 | END //
54 |
55 | CREATE OR REPLACE PROCEDURE run_matching_process (
56 | _interval ENUM("second", "minute", "hour", "day", "week", "month")
57 | ) RETURNS BIGINT
58 | AS
59 | DECLARE
60 | _ts DATETIME = NOW(6);
61 | _count BIGINT;
62 | BEGIN
63 | INSERT INTO notifications SELECT _ts, * FROM match_offers_to_subscribers(_interval);
64 |
65 | _count = row_count();
66 |
67 | INSERT INTO subscribers_last_notification
68 | SELECT city_id, subscriber_id, ts
69 | FROM notifications
70 | WHERE ts = _ts
71 | ON DUPLICATE KEY UPDATE last_notification = _ts;
72 |
73 | RETURN _count;
74 | END //
75 |
76 | CREATE OR REPLACE PROCEDURE update_segments (
77 | _since DATETIME(6), _until DATETIME(6)
78 | ) AS BEGIN
79 | INSERT INTO subscriber_segments
80 | SELECT * FROM dynamic_subscriber_segments(_since, _until)
81 | ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at);
82 | END //
83 |
84 | CREATE OR REPLACE PROCEDURE prune_segments (
85 | _until DATETIME(6)
86 | ) AS BEGIN
87 | DELETE FROM subscriber_segments WHERE expires_at <= _until;
88 | END //
89 |
90 | CREATE OR REPLACE PROCEDURE update_sessions (
91 | _session_id TEXT, _lease_duration_sections INT
92 | ) AS DECLARE
93 | _num_alive_controllers QUERY(c INT) =
94 | SELECT COUNT(*) FROM sessions
95 | WHERE is_controller AND expires_at > NOW(6);
96 |
97 | _num_transactions QUERY(i INT) = SELECT @@trancount;
98 | BEGIN
99 | -- make sure this session exists
100 | INSERT INTO sessions
101 | SET
102 | session_id = _session_id,
103 | expires_at = NOW() + INTERVAL _lease_duration_sections SECOND
104 | ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at);
105 |
106 | START TRANSACTION;
107 |
108 | -- ensure this session is the only controller if no other alive controllers are present
109 | IF SCALAR(_num_alive_controllers) = 0 THEN
110 | UPDATE sessions
111 | SET is_controller = (session_id = _session_id);
112 | END IF;
113 |
114 | -- echo the session details to the caller
115 | ECHO SELECT
116 | session_id, is_controller, expires_at
117 | FROM sessions
118 | WHERE session_id = _session_id;
119 |
120 | COMMIT;
121 |
122 | -- delete any expired sessions (with a bit of lag)
123 | DELETE FROM sessions
124 | WHERE NOW(6) > (expires_at + INTERVAL (_lease_duration_sections * 2) SECOND);
125 |
126 | EXCEPTION
127 | WHEN OTHERS THEN
128 | IF SCALAR(_num_transactions) > 0 THEN
129 | ROLLBACK;
130 | END IF;
131 | END //
132 |
133 | DELIMITER ;
--------------------------------------------------------------------------------
/sql/functions.sql:
--------------------------------------------------------------------------------
1 | DELIMITER //
2 |
3 | CREATE OR REPLACE FUNCTION date_sub_dynamic (
4 | _dt DATETIME(6),
5 | _interval ENUM("second", "minute", "hour", "day", "week", "month")
6 | ) RETURNS DATETIME(6) AS
7 | BEGIN
8 | RETURN CASE _interval
9 | WHEN "second" THEN _dt - INTERVAL 1 SECOND
10 | WHEN "minute" THEN _dt - INTERVAL 1 MINUTE
11 | WHEN "hour" THEN _dt - INTERVAL 1 HOUR
12 | WHEN "day" THEN _dt - INTERVAL 1 DAY
13 | WHEN "week" THEN _dt - INTERVAL 1 WEEK
14 | WHEN "month" THEN _dt - INTERVAL 1 MONTH
15 | END;
16 | END //
17 |
18 | CREATE OR REPLACE FUNCTION date_add_dynamic (
19 | _dt DATETIME(6),
20 | _interval ENUM("second", "minute", "hour", "day", "week", "month")
21 | ) RETURNS DATETIME(6) AS
22 | BEGIN
23 | RETURN CASE _interval
24 | WHEN "second" THEN _dt + INTERVAL 1 SECOND
25 | WHEN "minute" THEN _dt + INTERVAL 1 MINUTE
26 | WHEN "hour" THEN _dt + INTERVAL 1 HOUR
27 | WHEN "day" THEN _dt + INTERVAL 1 DAY
28 | WHEN "week" THEN _dt + INTERVAL 1 WEEK
29 | WHEN "month" THEN _dt + INTERVAL 1 MONTH
30 | END;
31 | END //
32 |
33 | CREATE OR REPLACE FUNCTION encode_open_location_code (
34 | _lonlat GEOGRAPHYPOINT,
35 | codeLength INT DEFAULT 12
36 | ) RETURNS TEXT AS
37 | DECLARE
38 | SEPARATOR_ text = '+';
39 | SEPARATOR_POSITION_ int = 8;
40 | PADDING_CHARACTER_ text = '0';
41 | CODE_ALPHABET_ text = '23456789CFGHJMPQRVWX';
42 | ENCODING_BASE_ int = CHARACTER_LENGTH(CODE_ALPHABET_);
43 | LATITUDE_MAX_ int = 90;
44 | LONGITUDE_MAX_ int = 180;
45 | MAX_DIGIT_COUNT_ int = 15;
46 | PAIR_CODE_LENGTH_ int = 10;
47 | PAIR_PRECISION_ decimal = POWER(ENCODING_BASE_, 3);
48 | GRID_CODE_LENGTH_ int = MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_;
49 | GRID_COLUMNS_ int = 4;
50 | GRID_ROWS_ int = 5;
51 | FINAL_LAT_PRECISION_ decimal = PAIR_PRECISION_ * POWER(GRID_ROWS_, MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_);
52 | FINAL_LNG_PRECISION_ decimal = PAIR_PRECISION_ * POWER(GRID_COLUMNS_, MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_);
53 | latitude double = geography_latitude(_lonlat);
54 | longitude double = geography_longitude(_lonlat);
55 | code text = '';
56 | latVal decimal = 0;
57 | lngVal decimal = 0;
58 | latDigit smallint;
59 | lngDigit smallint;
60 | ndx smallint;
61 | i_ smallint;
62 | BEGIN
63 | -- This function has been ported from:
64 | -- http://github.com/google/open-location-code/blob/cafb35c0d74dd0c06b6a75c05f89e32a972b7b23/plpgsql/pluscode_functions.sql#L313
65 | -- Licensed under the Apache License, Version 2.0 (the 'License')
66 |
67 | IF ((codeLength < 2) OR ((codeLength < PAIR_CODE_LENGTH_) AND (codeLength % 2 = 1)) OR (codeLength > MAX_DIGIT_COUNT_)) THEN
68 | RAISE USER_EXCEPTION(CONCAT('Invalid Open Location Code length - ', codeLength));
69 | END IF;
70 |
71 | latVal = floor(round((latitude + LATITUDE_MAX_) * FINAL_LAT_PRECISION_, 6));
72 | lngVal = floor(round((longitude + LONGITUDE_MAX_) * FINAL_LNG_PRECISION_, 6));
73 |
74 | IF (codeLength > PAIR_CODE_LENGTH_) THEN
75 | i_ = 0;
76 | WHILE (i_ < (MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_)) LOOP
77 | latDigit = latVal % GRID_ROWS_;
78 | lngDigit = lngVal % GRID_COLUMNS_;
79 | ndx = (latDigit * GRID_COLUMNS_) + lngDigit;
80 | code = concat(substr(CODE_ALPHABET_, ndx + 1, 1), code);
81 | latVal = latVal DIV GRID_ROWS_;
82 | lngVal = lngVal DIV GRID_COLUMNS_;
83 | i_ = i_ + 1;
84 | END LOOP;
85 | ELSE
86 | latVal = latVal DIV power(GRID_ROWS_, GRID_CODE_LENGTH_);
87 | lngVal = lngVal DIV power(GRID_COLUMNS_, GRID_CODE_LENGTH_);
88 | END IF;
89 |
90 | i_ = 0;
91 | WHILE (i_ < (PAIR_CODE_LENGTH_ / 2)) LOOP
92 | code = concat(substr(CODE_ALPHABET_, (lngVal % ENCODING_BASE_) + 1, 1), code);
93 | code = concat(substr(CODE_ALPHABET_, (latVal % ENCODING_BASE_) + 1, 1), code);
94 | latVal = latVal DIV ENCODING_BASE_;
95 | lngVal = lngVal DIV ENCODING_BASE_;
96 | i_ = i_ + 1;
97 | END LOOP;
98 |
99 | code = concat(
100 | substr(code, 1, SEPARATOR_POSITION_),
101 | SEPARATOR_,
102 | substr(code, SEPARATOR_POSITION_ + 1)
103 | );
104 |
105 | IF (codeLength > SEPARATOR_POSITION_) THEN
106 | RETURN substr(code, 1, codeLength+1);
107 | ELSE
108 | RETURN substr(code, 1, codeLength);
109 | END IF;
110 | END //
111 |
112 | DELIMITER ;
--------------------------------------------------------------------------------
/web/src/components/EnableSimulatorButton.tsx:
--------------------------------------------------------------------------------
1 | import { Icon, InfoIcon } from "@chakra-ui/icons";
2 | import {
3 | Alert,
4 | AlertDescription,
5 | AlertIcon,
6 | AlertTitle,
7 | Box,
8 | Button,
9 | FormControl,
10 | FormControlProps,
11 | FormLabel,
12 | Switch,
13 | SwitchProps,
14 | Text,
15 | Tooltip,
16 | useColorModeValue,
17 | } from "@chakra-ui/react";
18 | import * as React from "react";
19 | import { useRecoilState, useRecoilValue } from "recoil";
20 |
21 | import { trackAnalyticsEvent } from "@/analytics";
22 | import { setSessionController } from "@/data/queries";
23 | import { connectionConfig, simulatorEnabled } from "@/data/recoil";
24 | import { useConnectionState } from "@/view/hooks/hooks";
25 | import { useSession } from "@/view/hooks/useSession";
26 |
27 | export const EnableSimulatorWarning = () => {
28 | return (
29 |
30 |
31 |
32 | The simulator is disabled
33 |
34 | This application uses a simulator to generate new data like live
35 | notifications and subscribers. To experience the full power of
36 | real-time digital marketing, please enable the simulator in the nav
37 | bar.
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export const SimulatorToggler = ({
45 | switchProps,
46 | containerProps,
47 | }: {
48 | switchProps?: SwitchProps;
49 | containerProps?: FormControlProps;
50 | }) => {
51 | const [enabled, setEnabled] = useRecoilState(simulatorEnabled);
52 | const { session, refresh: refreshSession } = useSession();
53 | const config = useRecoilValue(connectionConfig);
54 | const { connected, initialized } = useConnectionState();
55 | const [toggling, setToggling] = React.useState(false);
56 |
57 | const onToggleSimulator = React.useCallback(async () => {
58 | setToggling(true);
59 | const newState = !enabled;
60 | trackAnalyticsEvent("change-simulator-state", {
61 | enabled: newState,
62 | });
63 | if (connected && initialized) {
64 | await setSessionController(config, session.sessionID, newState);
65 | }
66 | setEnabled(newState);
67 | refreshSession();
68 | setToggling(false);
69 | }, [
70 | config,
71 | connected,
72 | initialized,
73 | session.sessionID,
74 | enabled,
75 | refreshSession,
76 | setEnabled,
77 | ]);
78 |
79 | return (
80 |
81 |
89 |
90 | Simulator
91 |
92 |
93 |
94 |
101 |
102 | );
103 | };
104 |
105 | export const SimulatorButton = () => {
106 | return (
107 |
111 | The simulator generates live notifications and subscribers even if the
112 | application browser window is closed. Toggle off to stop new data
113 | generation or suspend cluster in SingleStoreDB portal.
114 |
115 | }
116 | hasArrow
117 | textAlign="center"
118 | >
119 |
134 |
135 | );
136 | };
137 |
--------------------------------------------------------------------------------
/web/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // Please refer to https://github.com/eslint/eslint/issues/3458#issuecomment-648719715
2 | require("@rushstack/eslint-config/patch/modern-module-resolution");
3 |
4 | module.exports = {
5 | parser: "@typescript-eslint/parser",
6 | plugins: [
7 | "@typescript-eslint",
8 | "react",
9 | "babel",
10 | "switch-case",
11 | "simple-import-sort",
12 | "import",
13 | "react-hooks",
14 | "unused-imports",
15 | "@singlestore/react-hooks-disable-import",
16 | ],
17 | env: {
18 | node: true,
19 | browser: true,
20 | jest: true,
21 | },
22 | parserOptions: {
23 | sourceType: "module",
24 | emcaVersion: 6,
25 | esversion: 6,
26 | ecmaFeatures: {
27 | jsx: true,
28 | modules: true,
29 | },
30 | },
31 | settings: {
32 | react: {
33 | version: "16.12.0",
34 | },
35 | },
36 | rules: {
37 | "no-undef": [0],
38 | "no-debugger": "error",
39 | "no-constant-condition": [0],
40 | "no-console": [0],
41 | "no-irregular-whitespace": [0],
42 | "object-shorthand": 2,
43 | eqeqeq: ["error"],
44 | "switch-case/newline-between-switch-case": [
45 | "error",
46 | "always",
47 | { fallthrough: "never" },
48 | ],
49 | "prefer-const": "error",
50 |
51 | "react/display-name": 0,
52 | "react/forbid-prop-types": 1,
53 | "react/jsx-boolean-value": 1,
54 | "react/jsx-curly-spacing": 1,
55 | "react/jsx-handler-names": 1,
56 | "react/jsx-key": 0,
57 | "react/jsx-max-props-per-line": 0,
58 | "react/jsx-no-bind": 0,
59 | "react/jsx-no-duplicate-props": 1,
60 | "react/jsx-no-literals": 0,
61 | "react/jsx-no-undef": 1,
62 | "react/jsx-pascal-case": [1, { ignore: ["LSM"] }],
63 | "jsx-quotes": 1,
64 | "react/jsx-sort-props": 0,
65 | "react/jsx-uses-react": 1,
66 | "react/jsx-uses-vars": 1,
67 | "react/no-danger": 1,
68 | "react/no-deprecated": 1,
69 | "react/no-did-mount-set-state": 1,
70 | "react/no-did-update-set-state": 1,
71 | "react/no-direct-mutation-state": 1,
72 | "react/no-is-mounted": 1,
73 | "react/no-multi-comp": 0,
74 | "react/no-set-state": 0,
75 | "react/no-string-refs": 1,
76 | "react/no-unknown-property": 1,
77 | "react/prefer-es6-class": 1,
78 | "react/react-in-jsx-scope": 1,
79 | "react/self-closing-comp": 1,
80 | "react/sort-comp": 0,
81 | "react/jsx-wrap-multilines": 1,
82 | "react/jsx-fragments": [1, "syntax"],
83 | "react/jsx-curly-brace-presence": [2, "never"],
84 |
85 | "babel/semi": [2, "always"],
86 |
87 | "@typescript-eslint/no-unused-vars": 0,
88 | "unused-imports/no-unused-imports": 2,
89 | "unused-imports/no-unused-vars": [
90 | 2,
91 | {
92 | vars: "all",
93 | varsIgnorePattern: "^_",
94 | args: "after-used",
95 | argsIgnorePattern: "^_",
96 | },
97 | ],
98 | "@typescript-eslint/array-type": ["error", { default: "generic" }],
99 |
100 | "simple-import-sort/imports": [
101 | "error",
102 | {
103 | // The default grouping, but with css files at the end. This
104 | // prevents the currently imported styling to be overwritten by
105 | // imported components styling
106 | groups: [
107 | ["^.+\\.graphql|gql$$"],
108 | ["^\\u0000"],
109 | ["^@?\\w"],
110 | ["^[^.]"],
111 | ["^\\."],
112 | ["^.+\\.s?css$"],
113 | ],
114 | },
115 | ],
116 | "import/no-duplicates": "error",
117 | "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
118 | "react-hooks/exhaustive-deps": "error", // Checks effect dependencies
119 | "@singlestore/react-hooks-disable-import/react-hooks-disable-import":
120 | "error",
121 | },
122 | };
123 |
--------------------------------------------------------------------------------
/web/src/components/IngestChart.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Text, useColorMode } from "@chakra-ui/react";
2 | import {
3 | AnimatedLineSeries,
4 | Axis,
5 | darkTheme,
6 | lightTheme,
7 | Tooltip,
8 | XYChart,
9 | } from "@visx/xychart";
10 | import { RenderTooltipParams } from "@visx/xychart/lib/components/Tooltip";
11 | import { format } from "d3-format";
12 | import * as React from "react";
13 | import useSWR from "swr";
14 |
15 | import { Loader } from "@/components/customcomponents/loader/Loader";
16 | import { ConnectionConfig } from "@/data/client";
17 | import { estimatedRowCountObj } from "@/data/queries";
18 | import { Timeseries, TimeseriesPoint } from "@/data/timeseries";
19 |
20 | const SI_FORMAT = format("~s");
21 |
22 | export const useIngestChartData = (
23 | config: ConnectionConfig,
24 | ...tables: Array
25 | ) => {
26 | type ReturnType = { [name in TableName]: Timeseries };
27 | const emptyCache = React.useMemo(
28 | () => tables.reduce((a, n) => ({ ...a, [n]: [] }), {} as ReturnType),
29 | [tables]
30 | );
31 | const cache = React.useRef(emptyCache);
32 |
33 | const { data } = useSWR(
34 | ["estimatedRowCount.timeseries", ...tables],
35 | async () => {
36 | const newData = await estimatedRowCountObj(config, ...tables);
37 | const now = new Date();
38 |
39 | cache.current = tables.reduce((memo, name) => {
40 | const ts = cache.current[name];
41 |
42 | // add new point
43 | ts.push([now, newData[name]]);
44 |
45 | // truncate to last 30 points
46 | memo[name] = ts.slice(-30);
47 |
48 | return memo;
49 | }, {} as ReturnType);
50 |
51 | return cache.current;
52 | },
53 | { refreshInterval: 1000 }
54 | );
55 |
56 | if (data) {
57 | return data;
58 | }
59 |
60 | return emptyCache;
61 | };
62 |
63 | type Props = {
64 | data: { [name in TableName]: Timeseries };
65 | yAxisLabel: string;
66 | width?: number;
67 | height: number;
68 | };
69 |
70 | export const IngestChart = ({
71 | data,
72 | yAxisLabel,
73 | ...props
74 | }: Props) => {
75 | const { colorMode } = useColorMode();
76 | const tables = Object.keys(data) as Array;
77 |
78 | const renderTooltip = React.useCallback(
79 | ({ tooltipData, colorScale }: RenderTooltipParams) => {
80 | if (!colorScale || !tooltipData) {
81 | return null;
82 | }
83 | return tables
84 | .sort(
85 | (a, b) =>
86 | tooltipData.datumByKey[b].datum[1] -
87 | tooltipData.datumByKey[a].datum[1]
88 | )
89 | .map((name) => {
90 | const { datum } = tooltipData.datumByKey[name];
91 | return (
92 |
93 | {name}: {format(".4~s")(datum[1])}
94 |
95 | );
96 | });
97 | },
98 | [tables]
99 | );
100 |
101 | const yTickFormat = React.useCallback(
102 | (v: number) => SI_FORMAT(v).replace("G", "B"),
103 | []
104 | );
105 |
106 | if (tables.some((name) => data[name].length < 2)) {
107 | return (
108 |
109 |
110 |
111 | );
112 | }
113 |
114 | const lines = tables.map((name) => (
115 | datum[0]}
120 | yAccessor={(datum) => datum[1]}
121 | />
122 | ));
123 |
124 | return (
125 |
132 |
133 |
140 | {lines}
141 |
148 |
149 | );
150 | };
151 |
--------------------------------------------------------------------------------
/web/src/components/HeatMap.tsx:
--------------------------------------------------------------------------------
1 | import { useConst } from "@chakra-ui/react";
2 | import * as d3color from "d3-color";
3 | import { ScaleSequential, scaleSequential } from "d3-scale";
4 | import { Bounds, Point } from "pigeon-maps";
5 | import * as PIXI from "pixi.js";
6 | import * as React from "react";
7 |
8 | import { PixiMap, PixiMapProps, UsePixiRenderer } from "@/components/PixiMap";
9 | import { Polygon, WKTPolygonToPolygon } from "@/geo";
10 | import { useDebounce } from "@/view/hooks/hooks";
11 |
12 | // convert number (range 0-1) to color (hex)
13 | export type ColorInterpolater = (t: number) => string;
14 |
15 | interface RGBInterface {
16 | rgb(): { r: number; g: number; b: number };
17 | darker(n: number): RGBInterface;
18 | }
19 |
20 | const colorToRGBNumber = (c: RGBInterface): number => {
21 | const { r, g, b } = c.rgb();
22 | return (r << 16) | (g << 8) | b;
23 | };
24 |
25 | type CellConfig = {
26 | wktPolygon: string;
27 | value: number;
28 | };
29 |
30 | class HeatmapCell extends PIXI.Container {
31 | points: Polygon;
32 | polygon: PIXI.Graphics;
33 | hovering = false;
34 | color: number;
35 | hoverColor: number;
36 |
37 | constructor(
38 | public config: CellConfig,
39 | colorScale: ScaleSequential
40 | ) {
41 | super();
42 | this.points = WKTPolygonToPolygon(config.wktPolygon);
43 | this.polygon = new PIXI.Graphics();
44 | this.addChild(this.polygon);
45 |
46 | // White color may not be visible due to light background of the map.
47 | // Adding extra color opacity to avoid pure white color.
48 | const color = colorScale(config.value + 0.01);
49 | this.color = colorToRGBNumber(color);
50 | this.hoverColor = colorToRGBNumber(color.darker(1));
51 |
52 | this.polygon.interactive = true;
53 | this.polygon.on("mouseover", () => {
54 | this.hovering = true;
55 | });
56 | this.polygon.on("mouseout", () => {
57 | this.hovering = false;
58 | });
59 | }
60 |
61 | update(latLngToPixel: (latlng: Point) => Point) {
62 | const color = this.hovering ? this.hoverColor : this.color;
63 | this.polygon.clear();
64 | this.polygon.lineStyle(1.5, color, 0.5);
65 | this.polygon.beginFill(color, 0.2);
66 | this.polygon.drawPolygon(
67 | this.points.flatMap(([lng, lat]) => latLngToPixel([lat, lng]))
68 | );
69 | this.polygon.endFill();
70 | }
71 | }
72 |
73 | type RendererProps = {
74 | useCells: (bounds: Bounds, callback: (cells: Array) => void) => void;
75 | getCellConfig: (cell: T) => CellConfig;
76 | colorInterpolater: ColorInterpolater;
77 | };
78 |
79 | const makeUseRenderer =
80 | (props: RendererProps): UsePixiRenderer =>
81 | ({ scene, latLngToPixel, bounds }) => {
82 | const debouncedBounds = useDebounce(bounds, 100);
83 | const scale = useConst(() =>
84 | scaleSequential((n: number) =>
85 | d3color.cubehelix(props.colorInterpolater(n))
86 | )
87 | );
88 |
89 | props.useCells(debouncedBounds, (cells) => {
90 | // clear the scene
91 | scene.removeChildren();
92 |
93 | let minValue = Infinity;
94 | let maxValue = -Infinity;
95 |
96 | const cfgs: Array = [];
97 | for (const cell of cells) {
98 | const cfg = props.getCellConfig(cell);
99 | cfgs.push(cfg);
100 |
101 | if (cfg.value < minValue) {
102 | minValue = cfg.value;
103 | }
104 | if (cfg.value > maxValue) {
105 | maxValue = cfg.value;
106 | }
107 | }
108 |
109 | // update the scale domain to match the data
110 | scale.domain([minValue, maxValue]);
111 |
112 | for (const cfg of cfgs) {
113 | scene.addChild(new HeatmapCell(cfg, scale));
114 | }
115 | });
116 |
117 | return {
118 | update: React.useCallback(() => {
119 | for (let i = 0; i < scene.children.length; i++) {
120 | const child = scene.children[i] as HeatmapCell;
121 | child.update(latLngToPixel);
122 | }
123 | }, [latLngToPixel, scene]),
124 | };
125 | };
126 |
127 | export type HeatmapProps = RendererProps &
128 | Omit, "useRenderer" | "options">;
129 |
130 | export const Heatmap = (props: HeatmapProps) => {
131 | const { useCells, getCellConfig, colorInterpolater, ...rest } = props;
132 | const useRenderer = React.useMemo(
133 | () => makeUseRenderer({ useCells, getCellConfig, colorInterpolater }),
134 | [colorInterpolater, getCellConfig, useCells]
135 | );
136 | return ;
137 | };
138 |
--------------------------------------------------------------------------------
/web/src/data/client.ts:
--------------------------------------------------------------------------------
1 | const rectifyHostAddress = (hostAddress: string) => {
2 | if (
3 | hostAddress.toLowerCase().startsWith("http://") ||
4 | hostAddress.toLowerCase().startsWith("https://") ||
5 | hostAddress === ""
6 | ) {
7 | return hostAddress;
8 | } else {
9 | return `https://${hostAddress}`;
10 | }
11 | };
12 |
13 | export type ConnectionConfig = {
14 | host: string;
15 | user: string;
16 | password: string;
17 | database: string;
18 | ctx?: AbortController;
19 | };
20 |
21 | export type ConnectionConfigOptionalDatabase = Omit<
22 | ConnectionConfig,
23 | "database"
24 | > & {
25 | database?: string;
26 | };
27 |
28 | export type SQLValue =
29 | | string
30 | | number
31 | | boolean
32 | | null
33 | | { [key: string]: SQLValue }
34 | | Array;
35 |
36 | export type Row = { [key: string]: SQLValue };
37 |
38 | const regexSQLErrorCode = /^Error (?\d+):/;
39 |
40 | const DEBUG = false;
41 |
42 | export class SQLError extends Error {
43 | code: number;
44 | sql: string;
45 |
46 | constructor(msg: string, sql: string, code?: number) {
47 | super(msg);
48 | // https://stackoverflow.com/a/41429145/65872
49 | Object.setPrototypeOf(this, SQLError.prototype);
50 | this.sql = sql;
51 |
52 | if (code) {
53 | this.code = code;
54 | } else {
55 | const matched = msg.match(regexSQLErrorCode);
56 | this.code = matched ? parseInt(matched.groups?.code || "-1", 10) : -1;
57 | }
58 | }
59 |
60 | isUnknownDatabase() {
61 | return this.code === 1049;
62 | }
63 |
64 | isDatabaseRecovering() {
65 | return this.code === 2269;
66 | }
67 |
68 | isPlanMissing() {
69 | return this.code === 1885;
70 | }
71 | }
72 |
73 | export const QueryOne = async (
74 | config: ConnectionConfigOptionalDatabase,
75 | sql: string,
76 | ...args: Array
77 | ): Promise => {
78 | const rows = await Query(config, sql, ...args);
79 | if (rows.length !== 1) {
80 | throw new SQLError("Expected exactly one row", sql);
81 | }
82 |
83 | return rows[0];
84 | };
85 |
86 | export const Query = async (
87 | config: ConnectionConfigOptionalDatabase,
88 | sql: string,
89 | ...args: Array
90 | ): Promise> => {
91 | const data = await fetchEndpoint("query/rows", config, sql, ...args);
92 |
93 | if (data.results.length !== 1) {
94 | throw new SQLError("Expected exactly one result set", sql);
95 | }
96 |
97 | return data.results[0].rows;
98 | };
99 |
100 | export const QueryNoDb = (
101 | config: ConnectionConfigOptionalDatabase,
102 | sql: string,
103 | ...args: Array
104 | ): Promise> => Query({ ...config, database: undefined }, sql, ...args);
105 |
106 | export const QueryTuples = async <
107 | T extends [...Array] = Array
108 | >(
109 | config: ConnectionConfigOptionalDatabase,
110 | sql: string,
111 | ...args: Array
112 | ): Promise> => {
113 | const data = await fetchEndpoint("query/tuples", config, sql, ...args);
114 |
115 | if (data.results.length !== 1) {
116 | throw new SQLError("Expected exactly one result set", sql);
117 | }
118 |
119 | return data.results[0].rows;
120 | };
121 |
122 | export const ExecNoDb = (
123 | config: ConnectionConfigOptionalDatabase,
124 | sql: string,
125 | ...args: Array
126 | ): Promise<{ lastInsertId: number; rowsAffected: number }> =>
127 | fetchEndpoint("exec", { ...config, database: undefined }, sql, ...args);
128 |
129 | export const Exec = (
130 | config: ConnectionConfigOptionalDatabase,
131 | sql: string,
132 | ...args: Array
133 | ): Promise<{ lastInsertId: number; rowsAffected: number }> =>
134 | fetchEndpoint("exec", config, sql, ...args);
135 |
136 | const fetchEndpoint = async (
137 | endpoint: string,
138 | config: ConnectionConfigOptionalDatabase,
139 | sql: string,
140 | ...args: Array
141 | ) => {
142 | if (DEBUG) {
143 | console.log("running query", sql, args);
144 | }
145 |
146 | const response = await fetch(
147 | `${rectifyHostAddress(config.host)}/api/v2/${endpoint}`,
148 | {
149 | method: "POST",
150 | signal: config.ctx?.signal,
151 | headers: {
152 | "Content-Type": "application/json",
153 | Authorization: `Basic ${btoa(`${config.user}:${config.password}`)}`,
154 | },
155 | body: JSON.stringify({ sql, args, database: config.database }),
156 | }
157 | );
158 |
159 | if (!response.ok) {
160 | throw new SQLError(await response.text(), sql);
161 | }
162 |
163 | const data = await response.json();
164 |
165 | if (data.error) {
166 | throw new SQLError(data.error.message, sql, data.error.code);
167 | }
168 | return data;
169 | };
170 |
--------------------------------------------------------------------------------
/web/src/components/dataConfigForm/DatabaseConfigFormManual.tsx:
--------------------------------------------------------------------------------
1 | import { Box, SimpleGrid, Stack, Tooltip, useToast } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { useRecoilState } from "recoil";
4 |
5 | import { ConfigInput } from "@/components/ConfigInput";
6 | import { ScaleFactorSelector } from "@/components/ScaleFactorSelector";
7 | import { connectToDB } from "@/data/queries";
8 | import {
9 | connectionDatabase,
10 | connectionHost,
11 | connectionPassword,
12 | connectionUser,
13 | } from "@/data/recoil";
14 |
15 | import { InvertedPrimaryButton } from "../customcomponents/Button";
16 | import { Loader } from "../customcomponents/loader/Loader";
17 |
18 | type Props = {
19 | showDatabase?: boolean;
20 | showScaleFactor?: boolean;
21 | };
22 |
23 | export const DatabaseConfigFormManual = ({
24 | showDatabase,
25 | showScaleFactor,
26 | }: Props) => {
27 | const toast = useToast();
28 | const [loading, setLoading] = React.useState(false);
29 | const [host, setHost] = useRecoilState(connectionHost);
30 | const [user, setUser] = useRecoilState(connectionUser);
31 | const [password, setPassword] = useRecoilState(connectionPassword);
32 | const [database, setDatabase] = useRecoilState(connectionDatabase);
33 |
34 | const [localHost, setLocalHost] = React.useState(host);
35 | const [localUser, setLocalUser] = React.useState(user);
36 | const [localPassword, setLocalPassword] = React.useState(password);
37 | const [localDatabase, setLocalDatabase] = React.useState(database);
38 |
39 | const connect = () => {
40 | setLoading(true);
41 | const config = {
42 | host: localHost,
43 | password: localPassword,
44 | user: localUser,
45 | };
46 | connectToDB(config).then((connected) => {
47 | setLoading(false);
48 | let database = "martech";
49 | if (localDatabase) {
50 | database = localDatabase;
51 | }
52 | if (connected === true) {
53 | setHost(localHost);
54 | setUser(localUser);
55 | setPassword(localPassword);
56 | setDatabase(database);
57 | } else {
58 | toast({
59 | title: "An error occured",
60 | description: `${connected.message}`,
61 | status: "error",
62 | duration: 3000,
63 | isClosable: true,
64 | });
65 | }
66 | });
67 | };
68 |
69 | const connectDisabled =
70 | localHost === "" || localUser === "" || localPassword === "" || loading;
71 |
72 | let databaseInput;
73 | if (showDatabase) {
74 | databaseInput = (
75 |
82 | );
83 | }
84 |
85 | let scaleFactor;
86 | if (showScaleFactor) {
87 | scaleFactor = ;
88 | }
89 |
90 | let connectButtonContainer = <>Connect>;
91 | if (loading) {
92 | connectButtonContainer = (
93 |
94 |
95 | Connecting...
96 |
97 | );
98 | }
99 |
100 | const handleEnterKeyPress = (e: React.KeyboardEvent) => {
101 | if (e.key.toLowerCase() === "enter") {
102 | connect();
103 | }
104 | };
105 |
106 | return (
107 |
108 |
116 |
117 |
125 |
133 |
134 | {databaseInput}
135 | {scaleFactor}
136 |
137 |
143 |
149 | {connectButtonContainer}
150 |
151 |
152 |
153 | );
154 | };
155 |
--------------------------------------------------------------------------------
/web/src/render/useNotificationsRenderer.ts:
--------------------------------------------------------------------------------
1 | import { easeCubicIn, easeExp, easeLinear, easeQuadOut } from "d3-ease";
2 | import { Point } from "pigeon-maps";
3 | import * as PIXI from "pixi.js";
4 | import * as React from "react";
5 | import { useRecoilValue } from "recoil";
6 | import useSWR from "swr";
7 |
8 | import { trackAnalyticsEvent } from "@/analytics";
9 | import { UsePixiRenderer } from "@/components/PixiMap";
10 | import { City, getCities, queryNotificationsInBounds } from "@/data/queries";
11 | import { connectionConfig } from "@/data/recoil";
12 | import { toISOStringNoTZ } from "@/datetime";
13 | import { useConnectionState, useDebounce } from "@/view/hooks/hooks";
14 |
15 | const MAX_NOTIFICATIONS = 100;
16 | const REFRESH_INTERVAL = 1000;
17 |
18 | class Pulse extends PIXI.Container {
19 | static lifetime = 1.5;
20 | static markerColor = 0x553acf;
21 | static pulseColor = 0x553acf;
22 |
23 | latlng: Point;
24 | age = 0;
25 | marker: PIXI.Graphics;
26 | pulse: PIXI.Graphics;
27 |
28 | constructor(latlng: Point) {
29 | super();
30 |
31 | this.latlng = latlng;
32 |
33 | this.marker = new PIXI.Graphics();
34 | this.marker.beginFill(Pulse.markerColor).drawCircle(0, 0, 5).endFill();
35 | this.addChild(this.marker);
36 |
37 | this.pulse = new PIXI.Graphics();
38 | this.pulse.beginFill(Pulse.pulseColor);
39 | if (this.pulse.drawTorus) {
40 | this.pulse.drawTorus(0, 0, 4, 6);
41 | }
42 | this.pulse.endFill();
43 | this.addChild(this.pulse);
44 | }
45 |
46 | update(latLngToPixel: (latlng: Point) => Point, delta: number) {
47 | this.age += delta / 60;
48 |
49 | if (this.age > Pulse.lifetime && this.parent) {
50 | this.parent.removeChild(this);
51 | return;
52 | }
53 |
54 | const t = (this.age % Pulse.lifetime) / Pulse.lifetime;
55 |
56 | const eased = easeQuadOut(t);
57 | this.pulse.scale.set(1 + eased);
58 |
59 | const cutoff = 0.4;
60 | const alphaEase =
61 | t < cutoff
62 | ? easeCubicIn(t / cutoff)
63 | : 1 - easeLinear((t - cutoff) / (1 - cutoff));
64 | this.pulse.alpha = alphaEase;
65 |
66 | if (t < cutoff) {
67 | this.marker.alpha = alphaEase;
68 | } else {
69 | this.marker.alpha = 1 - easeExp((t - cutoff) / (1 - cutoff));
70 | }
71 |
72 | const [x, y] = latLngToPixel(this.latlng);
73 | this.x = x;
74 | this.y = y;
75 | }
76 | }
77 |
78 | export const useNotificationsDataKey = () => {
79 | const config = useRecoilValue(connectionConfig);
80 | const { initialized } = useConnectionState();
81 | return React.useMemo(
82 | () => ["notifications", config, initialized],
83 | [config, initialized]
84 | );
85 | };
86 |
87 | export const useCities = (onSuccess: (cities: Array) => void) => {
88 | const config = useRecoilValue(connectionConfig);
89 | const { initialized } = useConnectionState();
90 | return useSWR(["cities", config, initialized], () => getCities(config), {
91 | isPaused: () => !initialized,
92 | onSuccess,
93 | });
94 | };
95 |
96 | export const useNotificationsRenderer: UsePixiRenderer = ({
97 | scene,
98 | latLngToPixel,
99 | bounds,
100 | }) => {
101 | const timestampCursor = React.useRef(toISOStringNoTZ(new Date()));
102 | const config = useRecoilValue(connectionConfig);
103 | const { initialized } = useConnectionState();
104 | const debouncedBounds = useDebounce(bounds, 50);
105 | const swrKey = useNotificationsDataKey();
106 | const trackedNotifications = React.useRef(false);
107 |
108 | useSWR(
109 | swrKey,
110 | () =>
111 | queryNotificationsInBounds(
112 | config,
113 | timestampCursor.current,
114 | MAX_NOTIFICATIONS,
115 | debouncedBounds
116 | ),
117 | {
118 | refreshInterval: REFRESH_INTERVAL,
119 | isPaused: () => !initialized,
120 | onSuccess: (newNotifications) => {
121 | if (newNotifications.length > 0) {
122 | // we just want to log new notications once to avoid a lot of noise
123 | if (!trackedNotifications.current) {
124 | trackAnalyticsEvent("new-notifications");
125 | trackedNotifications.current = true;
126 | }
127 | timestampCursor.current = newNotifications[0][0];
128 |
129 | for (const [, lng, lat] of newNotifications) {
130 | scene.addChild(new Pulse([lat, lng]));
131 | }
132 | }
133 | },
134 | }
135 | );
136 |
137 | return {
138 | update: React.useCallback(
139 | (delta) => {
140 | // iterate in reverse since Pulses remove themselves when invisible
141 | for (let i = scene.children.length - 1; i >= 0; i--) {
142 | const child = scene.children[i] as Pulse;
143 | child.update(latLngToPixel, delta);
144 | }
145 | },
146 | [latLngToPixel, scene]
147 | ),
148 | };
149 | };
150 |
--------------------------------------------------------------------------------
/web/src/components/NeedHelpModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Grid,
4 | GridItem,
5 | Icon,
6 | Image,
7 | Link,
8 | Modal,
9 | ModalBody,
10 | ModalCloseButton,
11 | ModalContent,
12 | ModalFooter,
13 | ModalHeader,
14 | ModalOverlay,
15 | Stack,
16 | Text,
17 | useColorModeValue,
18 | } from "@chakra-ui/react";
19 | import * as React from "react";
20 | import { HiChevronRight } from "react-icons/hi";
21 |
22 | import CreateWorkspaceButtonSVG from "@/assets/needhelpmodal/create-workspace-button.svg";
23 | import RTDMConnectButtonSVG from "@/assets/needhelpmodal/rtdm-connect-button.svg";
24 | import WorkspaceConnectOptionSVG from "@/assets/needhelpmodal/workspace-connect-direct.svg";
25 |
26 | import { InvertedPrimaryButton } from "./customcomponents/Button";
27 |
28 | const HOW_IT_WORKS_STEPS = [
29 | {
30 | title: "1. Enter your workspace host address.",
31 | description: (
32 | <>
33 | Go to your SingleStoreDB workspace group page and copy
34 | SinglestoreDB endpoints by clicking in workspace Connect options.
35 | >
36 | ),
37 | imageSrc: CreateWorkspaceButtonSVG,
38 | },
39 | {
40 | title: "2. Paste Workspace Group's password",
41 | description: (
42 | <>
43 | This can be obtained from the Connect Directly flyout. The
44 | password can be reset in the Access tab of your Workspace Group.
45 | >
46 | ),
47 | imageSrc: WorkspaceConnectOptionSVG,
48 | },
49 | {
50 | title: "3. Select Martech database and connect",
51 | description: (
52 | <>
53 | If you already have the Martech dataset loaded, please introduce
54 | its database name. Otherwise, choose the new database name to load the
55 | dataset.
56 | >
57 | ),
58 | imageSrc: RTDMConnectButtonSVG,
59 | },
60 | ];
61 |
62 | const StepSeparator = () => (
63 |
69 |
76 |
77 | );
78 |
79 | const SingleStepContainer = ({
80 | title,
81 | description,
82 | imageSrc,
83 | }: {
84 | title: string;
85 | description: React.ReactNode;
86 | imageSrc: string;
87 | }) => (
88 |
96 |
103 |
104 | {title}
105 |
106 | {description}
107 |
108 | );
109 |
110 | const StepGridItems = () => {
111 | const numOfSteps = HOW_IT_WORKS_STEPS.length;
112 |
113 | const getStepSeparator = (index: number) => {
114 | // We do not add separator after the last step.
115 | if (index < numOfSteps - 1) {
116 | return ;
117 | }
118 | };
119 |
120 | return (
121 | <>
122 | {HOW_IT_WORKS_STEPS.map(({ title, description, imageSrc }, index) => (
123 |
124 |
129 | {/* We add a arrow separator in between each steps.*/}
130 | {getStepSeparator(index)}
131 |
132 | ))}
133 | >
134 | );
135 | };
136 |
137 | export const NeedHelpModal = () => {
138 | const [showModal, setShowModal] = React.useState(false);
139 |
140 | const openModal = () => setShowModal(true);
141 | const closeModal = () => setShowModal(false);
142 |
143 | return (
144 | <>
145 | Need help?
146 |
147 |
148 |
153 |
154 |
155 | How to connect the Real-Time Digital marketing app to
156 | SingleStoreDB?
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
170 | Start now
171 |
172 |
173 |
174 |
175 |
176 | >
177 | );
178 | };
179 |
--------------------------------------------------------------------------------
/web/src/data/models/useUpdateCityList.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SetterOrUpdater, useRecoilState, useRecoilValue } from "recoil";
3 |
4 | import { trackAnalyticsEvent } from "@/analytics";
5 | import { ConnectionConfig } from "@/data/client";
6 | import { createCity, removeCity } from "@/data/offers";
7 | import {
8 | City,
9 | getCities,
10 | lookupClosestCity,
11 | seedCityWithOffers,
12 | } from "@/data/queries";
13 | import { ScaleFactors } from "@/scalefactors";
14 |
15 | import {
16 | connectionConfig,
17 | errorUpdatingCities,
18 | isUpdatingCities,
19 | selectedCities,
20 | selectedCity,
21 | } from "../recoil";
22 |
23 | const getSelectedCitiesFromDatabase = async (
24 | config: ConnectionConfig,
25 | selectCityHook: [number, SetterOrUpdater],
26 | setIsUpdating: React.Dispatch>,
27 | setCities: React.Dispatch>>,
28 | setError: React.Dispatch>
29 | ) => {
30 | const [lastSelectedCityId, setLastSelectedCityId] = selectCityHook;
31 | setIsUpdating(true);
32 | try {
33 | // Fetch cities from database in SinglestoreDB.
34 | const cities = await getCities(config);
35 | setCities(cities);
36 |
37 | // Update lastSelectedCityId in case it is not found in selected cities from database.
38 | const doesLastSelectedCityExist = cities.find(
39 | (c) => c.id === lastSelectedCityId
40 | );
41 | if (!doesLastSelectedCityExist) {
42 | setLastSelectedCityId((cities[0] && cities[0].id) || -1);
43 | }
44 | } catch (error) {
45 | setError(error as Error);
46 | }
47 | setIsUpdating(false);
48 | };
49 |
50 | const addCityToDatabase = async (
51 | config: ConnectionConfig,
52 | point: [number, number],
53 | selectCityHook: [number, SetterOrUpdater],
54 | setIsUpdating: React.Dispatch>,
55 | setCities: React.Dispatch>>,
56 | setError: React.Dispatch>
57 | ) => {
58 | // The addCityToDatabase will add city along with city details in cities table from martech database.
59 | // This will also automatically update selectedCities in recoil after the new city is added.
60 |
61 | setIsUpdating(true);
62 | const city = await lookupClosestCity(config, point[1], point[0]);
63 | const cityConfig = {
64 | id: city.id,
65 | name: city.name,
66 | lonlat: <[number, number]>[city.centerLon, city.centerLat],
67 | diameter: city.diameter,
68 | };
69 | await createCity(config, cityConfig);
70 | trackAnalyticsEvent("create-city");
71 | await seedCityWithOffers(config, cityConfig, ScaleFactors[0]);
72 | await getSelectedCitiesFromDatabase(
73 | config,
74 | selectCityHook,
75 | setIsUpdating,
76 | setCities,
77 | setError
78 | );
79 | setIsUpdating(false);
80 | };
81 |
82 | const removeCityFromDatabase = async (
83 | config: ConnectionConfig,
84 | cityId: number,
85 | selectCityHook: [number, SetterOrUpdater],
86 | setIsUpdating: React.Dispatch>,
87 | setCities: React.Dispatch>>,
88 | setError: React.Dispatch>
89 | ) => {
90 | // The removeCityFromDatabase will remove city matching cityID from cities table in martech database.
91 | // This will also automatically update selectedCities in recoil after the city is removed.
92 |
93 | setIsUpdating(true);
94 | await removeCity(config, cityId);
95 | trackAnalyticsEvent("remove-city");
96 | await getSelectedCitiesFromDatabase(
97 | config,
98 | selectCityHook,
99 | setIsUpdating,
100 | setCities,
101 | setError
102 | );
103 | setIsUpdating(false);
104 | };
105 |
106 | export interface CityListHookReturnType {
107 | onCreateCity: (lat: number, lon: number) => void;
108 | onRemoveCity: (cityId: number) => void;
109 | updateCityList: () => void;
110 | }
111 |
112 | export const useUpdateCityList = (): CityListHookReturnType => {
113 | // The useUpdateCityList hook provides functionality to add or remove cities from cities database.
114 | // The RTDM will only fetch analytical data that will are related to city from the cities database.
115 | const [_selectedCities, setSelectedCities] = useRecoilState(selectedCities);
116 | const [_error, setError] = useRecoilState(errorUpdatingCities);
117 | const [_isUpdating, setIsUpdating] = useRecoilState(isUpdatingCities);
118 | const selectCityHook = useRecoilState(selectedCity);
119 | const config = useRecoilValue(connectionConfig);
120 |
121 | const onCreateCity = async (lat: number, lon: number) => {
122 | await addCityToDatabase(
123 | config,
124 | [lat, lon],
125 | selectCityHook,
126 | setIsUpdating,
127 | setSelectedCities,
128 | setError
129 | );
130 | };
131 | const onRemoveCity = async (cityId: number) => {
132 | await removeCityFromDatabase(
133 | config,
134 | cityId,
135 | selectCityHook,
136 | setIsUpdating,
137 | setSelectedCities,
138 | setError
139 | );
140 | };
141 |
142 | const updateCityList = async () => {
143 | await getSelectedCitiesFromDatabase(
144 | config,
145 | selectCityHook,
146 | setIsUpdating,
147 | setSelectedCities,
148 | setError
149 | );
150 | };
151 |
152 | return {
153 | onCreateCity,
154 | onRemoveCity,
155 | updateCityList,
156 | };
157 | };
158 |
--------------------------------------------------------------------------------
/web/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { CheckCircleIcon, CloseIcon } from "@chakra-ui/icons";
2 | import {
3 | Box,
4 | Center,
5 | Flex,
6 | Heading,
7 | Text,
8 | useColorModeValue,
9 | useToast,
10 | } from "@chakra-ui/react";
11 | import * as React from "react";
12 | import { Navigate, Route, Routes } from "react-router-dom";
13 | import { useRecoilState } from "recoil";
14 |
15 | import { useAnalytics } from "@/analytics";
16 | import { Loader } from "@/components/customcomponents/loader/Loader";
17 | import { Footer } from "@/components/Footer";
18 | import { Nav } from "@/components/navBar/Nav";
19 | import { AnalyticsDashboard } from "@/pages/Analytics";
20 | import { Configure } from "@/pages/Configure";
21 | import { Dashboard } from "@/pages/Dashboard";
22 | import { HomePage } from "@/pages/HomePage";
23 | import { useConnectionState } from "@/view/hooks/hooks";
24 |
25 | import { showWelcomeMessage } from "./data/recoil";
26 |
27 | const WelcomeMessageToast = () => {
28 | const [welcomeMessage, setwelcomeMessage] =
29 | useRecoilState(showWelcomeMessage);
30 | const toast = useToast();
31 | const { connected } = useConnectionState();
32 | const defaultFontTheme = useColorModeValue("white", "black");
33 |
34 | const ToastDescriptionComponent = () => {
35 | return (
36 |
37 | Hello!
38 | This is a demo application for an international marketing company
39 | serving simulated customer offers to millions of subscribers. You can:
40 |
41 | - Add or remove locations from dashboard
42 | - Inspect engagement under "Analytics"
43 | - Change schema settings with "Configure"
44 |
45 |
46 | );
47 | };
48 |
49 | const ToastBlock = () => (
50 |
61 | toast.close("welcomeToast")}
68 | />
69 |
70 |
71 |
72 | );
73 |
74 | // Welcome message toast on successfully connecting to singlestore for the first time.
75 | if (connected && welcomeMessage) {
76 | toast({
77 | id: "welcomeToast",
78 | duration: 9000,
79 | isClosable: true,
80 | position: "bottom",
81 | render: () => ,
82 | });
83 | }
84 | setwelcomeMessage(false);
85 |
86 | return <>>;
87 | };
88 |
89 | const PrivateRoute: React.FC<{ children: React.ReactElement }> = ({
90 | children,
91 | }) => {
92 | // Private routes will ensure user is connected to singlestore before using the route.
93 | // Will redirect to connection page in case user in not connected.
94 | const { connected, isValidatingConnection } = useConnectionState();
95 |
96 | if (!connected && !isValidatingConnection) {
97 | return ;
98 | }
99 |
100 | return (
101 |
102 |
103 | {children}
104 |
105 | );
106 | };
107 |
108 | const Analytics = ({ children }: { children: React.ReactNode }) => {
109 | useAnalytics();
110 | return <>{children}>;
111 | };
112 |
113 | const LayoutContainer = ({ children }: { children: React.ReactNode }) => {
114 | const { connected, isValidatingConnection } = useConnectionState();
115 | let childComponent = ;
116 |
117 | if (connected || !isValidatingConnection) {
118 | childComponent = <>{children}>;
119 | }
120 |
121 | return (
122 | <>
123 |
124 |
125 | {childComponent}
126 |
127 |
128 | >
129 | );
130 | };
131 |
132 | const RoutesBlock = () => {
133 | return (
134 |
135 | } />
136 |
140 |
141 |
142 | }
143 | />
144 |
148 |
149 |
150 | }
151 | />
152 |
156 |
157 |
158 | }
159 | />
160 | } />
161 |
162 | );
163 | };
164 |
165 | const App = () => {
166 | const loadingFallback = (
167 |
168 |
169 |
170 | );
171 |
172 | return (
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 | );
181 | };
182 |
183 | export default App;
184 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | bhayes@singlestore.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/web/src/data/recoil.ts:
--------------------------------------------------------------------------------
1 | import { atom, AtomEffect, atomFamily, DefaultValue, selector } from "recoil";
2 | import { v4 as uuidv4 } from "uuid";
3 |
4 | import { trackAnalyticsEvent } from "@/analytics";
5 | import { ConnectionConfig } from "@/data/client";
6 |
7 | import { defaultScaleFactor, ScaleFactor, ScaleFactors } from "../scalefactors";
8 | import { City, getCities } from "./queries";
9 |
10 | type LocalStorageEffectConfig = {
11 | encode: (v: T) => string;
12 | decode: (v: string) => T;
13 | };
14 |
15 | const localStorageEffect =
16 | (
17 | { encode, decode }: LocalStorageEffectConfig = {
18 | encode: JSON.stringify,
19 | decode: JSON.parse,
20 | }
21 | ): AtomEffect =>
22 | ({ setSelf, onSet, node }) => {
23 | const key = `recoil.localstorage.${node.key}`;
24 | const savedValue = localStorage.getItem(key);
25 | if (savedValue !== null) {
26 | setSelf(decode(savedValue));
27 | }
28 |
29 | onSet((newValue, _, isReset) => {
30 | isReset
31 | ? localStorage.removeItem(key)
32 | : localStorage.setItem(key, encode(newValue));
33 | });
34 | };
35 |
36 | const searchParamEffect =
37 | (searchParam: string): AtomEffect =>
38 | ({ setSelf }) => {
39 | const { location } = window;
40 | if (location) {
41 | const search = new URLSearchParams(location.search);
42 | setSelf(search.get(searchParam) || new DefaultValue());
43 | }
44 | };
45 |
46 | export const showWelcomeMessage = atom({
47 | key: "showWelcomeMessage",
48 | default: true,
49 | effects: [localStorageEffect()],
50 | });
51 |
52 | export const userSessionID = atom({
53 | key: "userID",
54 | default: uuidv4(),
55 | effects: [localStorageEffect()],
56 | });
57 |
58 | export const selectedCity = atom({
59 | key: "selectedCity",
60 | default: -1,
61 | });
62 |
63 | export const defaultSelectedCities = selector>({
64 | key: "defaultSelectedCities",
65 | get: async ({ get }) => {
66 | const config = get(connectionConfig);
67 | let selectedCities: Array = [];
68 | try {
69 | selectedCities = await getCities(config);
70 | } catch (error) {
71 | selectedCities = [];
72 | console.log("Failed to fetch selected cities details from the Database.");
73 | }
74 | return selectedCities;
75 | },
76 | });
77 |
78 | export const selectedCities = atom>({
79 | key: "selectedCities",
80 | default: defaultSelectedCities,
81 | });
82 |
83 | export const isUpdatingCities = atom({
84 | key: "isUpdatingCities",
85 | default: false,
86 | });
87 |
88 | export const errorUpdatingCities = atom({
89 | key: "errorUpdatingCities",
90 | default: undefined,
91 | });
92 |
93 | export const connectionHost = atom({
94 | key: "connectionHost",
95 | default: "http://127.0.0.1",
96 | effects: [localStorageEffect()],
97 | });
98 |
99 | export const connectionUser = atom({
100 | key: "connectionUser",
101 | default: "admin",
102 | effects: [localStorageEffect()],
103 | });
104 |
105 | export const connectionPassword = atom({
106 | key: "connectionPassword",
107 | default: "",
108 | effects: [localStorageEffect()],
109 | });
110 |
111 | export const connectionDatabase = atom({
112 | key: "connectionDatabase",
113 | default: "martech",
114 | effects: [localStorageEffect()],
115 | });
116 |
117 | export const portalDatabase = atom({
118 | key: "portalDatabase",
119 | default: "martech",
120 | effects: [searchParamEffect("database")],
121 | });
122 |
123 | export const portalHostname = atom({
124 | key: "portalHostname",
125 | default: null,
126 | effects: [searchParamEffect("hostname")],
127 | });
128 |
129 | export const portalCredentials = atom({
130 | key: "portalCredentials",
131 | default: null,
132 | effects: [searchParamEffect("credentials")],
133 | });
134 |
135 | export const portalConnectionConfig = selector({
136 | key: "portalConnectionConfig",
137 | get: async ({ get }) => {
138 | const portalHostnameValue = get(portalHostname);
139 | const portalCredentialsValue = get(portalCredentials);
140 | const portalDatabaseValue = get(portalDatabase);
141 |
142 | if (portalCredentialsValue) {
143 | let decodedCredentials;
144 | try {
145 | decodedCredentials = window.atob(portalCredentialsValue);
146 | } catch (e) {
147 | console.log(
148 | "Failed to decode Portal credentials, falling back to local config."
149 | );
150 | }
151 | if (portalHostnameValue && decodedCredentials && portalDatabaseValue) {
152 | const { username, password } = JSON.parse(decodedCredentials);
153 | if (username && password) {
154 | return {
155 | host: `https://${portalHostnameValue}`,
156 | user: username,
157 | password,
158 | database: portalDatabaseValue,
159 | };
160 | }
161 | }
162 | }
163 | },
164 | });
165 |
166 | export const connectionConfig = selector({
167 | key: "connectionConfig",
168 | get: ({ get }) => {
169 | const portalConfig = get(portalConnectionConfig);
170 | if (portalConfig) {
171 | trackAnalyticsEvent("portal-connection");
172 | return portalConfig;
173 | }
174 |
175 | const host = get(connectionHost);
176 | const user = get(connectionUser);
177 | const password = get(connectionPassword);
178 | const database = get(connectionDatabase);
179 | return { host, user, password, database };
180 | },
181 | cachePolicy_UNSTABLE: {
182 | eviction: "most-recent",
183 | },
184 | });
185 |
186 | export const configScaleFactor = atom({
187 | key: "configScaleFactor",
188 | default: defaultScaleFactor,
189 | effects: [
190 | localStorageEffect({
191 | encode: (v: ScaleFactor) => v.name,
192 | decode: (v: string) =>
193 | ScaleFactors.find((sf) => sf.name === v) || defaultScaleFactor,
194 | }),
195 | ],
196 | });
197 |
198 | export const simulatorEnabled = atom({
199 | key: "simulatorEnabled",
200 | default: true,
201 | effects: [localStorageEffect()],
202 | });
203 |
204 | export const databaseDrawerIsOpen = atom({
205 | key: "databaseDrawerIsOpen",
206 | default: false,
207 | });
208 |
209 | export const tickDurationMs = atomFamily({
210 | key: "tickDurationMs",
211 | default: undefined,
212 | });
213 |
214 | export const resettingSchema = atom({
215 | key: "resettingSchema",
216 | default: false,
217 | });
218 |
--------------------------------------------------------------------------------
/web/src/components/customcomponents/loader/loader.scss:
--------------------------------------------------------------------------------
1 | .single-common-components-loading {
2 | overflow-y: initial;
3 |
4 | > .inner {
5 | display: flex;
6 | align-items: center;
7 |
8 | flex-direction: column;
9 |
10 | &.inline-message {
11 | flex-direction: row;
12 |
13 | > .message {
14 | padding-top: 0;
15 | padding-left: "0.5714285714285714rem";
16 | }
17 | }
18 |
19 | > .message {
20 | padding-top: "1.7142857142857142rem";
21 | }
22 | }
23 |
24 | .svg-small {
25 | $svgSize: 16px;
26 | $strokeWidth: 2px;
27 |
28 | .circle {
29 | stroke-linecap: round;
30 | fill: transparent;
31 | stroke-width: $strokeWidth;
32 | }
33 |
34 | .circle-stroke {
35 | transform-origin: 50% 50% 0;
36 | stroke-linecap: round;
37 | fill: transparent;
38 | animation: spin-circle 5s linear infinite;
39 | stroke-dasharray: ($svgSize * 3.14);
40 | }
41 |
42 | @keyframes spin-circle {
43 | 0% {
44 | transform: rotate(0);
45 | stroke-dashoffset: ($svgSize * 4);
46 | }
47 | 50% {
48 | transform: rotate(720deg);
49 | stroke-dashoffset: ($svgSize * 5.3);
50 | }
51 | 100% {
52 | transform: rotate(1080deg);
53 | stroke-dashoffset: ($svgSize * 4);
54 | }
55 | }
56 | }
57 |
58 | .svg-large {
59 | @mixin animate-logo($animation-name) {
60 | animation: $animation-name 1.6s infinite linear;
61 | overflow: "clip";
62 | transform-origin: 50% 50%;
63 | }
64 |
65 | @include animate-logo(spin-scale-logo);
66 |
67 | .path-logo-one {
68 | @include animate-logo(fade-path-logo-one);
69 | }
70 |
71 | .path-logo-two {
72 | @include animate-logo(fade-path-logo-two);
73 | }
74 |
75 | .path-logo-three {
76 | @include animate-logo(fade-path-logo-three);
77 | }
78 |
79 | @keyframes spin-scale-logo {
80 | 0% {
81 | transform: rotate(0deg) scale(0.5);
82 | }
83 | 12% {
84 | transform: rotate(90deg) scale(0.6);
85 | }
86 | 22% {
87 | transform: rotate(180deg) scale(0.7);
88 | }
89 | 32% {
90 | transform: rotate(270deg) scale(0.78);
91 | }
92 | 40% {
93 | transform: rotate(360deg) scale(0.84);
94 | }
95 | 48% {
96 | transform: rotate(450deg) scale(0.9);
97 | }
98 | 56% {
99 | transform: rotate(540deg) scale(0.96);
100 | }
101 | 64% {
102 | transform: rotate(630deg) scale(1);
103 | }
104 | 69% {
105 | transform: rotate(720deg) scale(1);
106 | }
107 | 75% {
108 | transform: rotate(810deg) scale(0.92);
109 | }
110 | 82% {
111 | transform: rotate(900deg) scale(0.8);
112 | }
113 | 90% {
114 | transform: rotate(990deg) scale(0.65);
115 | }
116 | 100% {
117 | transform: rotate(1080deg) scale(0.5);
118 | }
119 | }
120 |
121 | @keyframes fade-path-logo-one {
122 | 0% {
123 | opacity: 0;
124 | }
125 | 12% {
126 | opacity: 0;
127 | }
128 | 22% {
129 | opacity: 0.05;
130 | }
131 | 32% {
132 | opacity: 0.15;
133 | }
134 | 40% {
135 | opacity: 0.3;
136 | }
137 | 48% {
138 | opacity: 0.5;
139 | }
140 | 56% {
141 | opacity: 0.75;
142 | }
143 | 64% {
144 | opacity: 1;
145 | }
146 | 69% {
147 | opacity: 1;
148 | }
149 | 75% {
150 | opacity: 0.5;
151 | }
152 | 82% {
153 | opacity: 0.25;
154 | }
155 | 90% {
156 | opacity: 0.05;
157 | }
158 | 100% {
159 | opacity: 0;
160 | }
161 | }
162 |
163 | @keyframes fade-path-logo-two {
164 | 0% {
165 | opacity: 0.1;
166 | }
167 | 12% {
168 | opacity: 0.15;
169 | }
170 | 22% {
171 | opacity: 0.3;
172 | }
173 | 32% {
174 | opacity: 0.5;
175 | }
176 | 40% {
177 | opacity: 0.75;
178 | }
179 | 48% {
180 | opacity: 1;
181 | }
182 | 56% {
183 | opacity: 1;
184 | }
185 | 64% {
186 | opacity: 1;
187 | }
188 | 69% {
189 | opacity: 1;
190 | }
191 | 75% {
192 | opacity: 0.8;
193 | }
194 | 82% {
195 | opacity: 0.5;
196 | }
197 | 90% {
198 | opacity: 0.25;
199 | }
200 | 100% {
201 | opacity: 0.1;
202 | }
203 | }
204 |
205 | @keyframes fade-path-logo-three {
206 | 0% {
207 | opacity: 0;
208 | }
209 | 12% {
210 | opacity: 0.05;
211 | }
212 | 22% {
213 | opacity: 0.15;
214 | }
215 | 32% {
216 | opacity: 0.3;
217 | }
218 | 40% {
219 | opacity: 0.5;
220 | }
221 | 48% {
222 | opacity: 0.7;
223 | }
224 | 56% {
225 | opacity: 0.9;
226 | }
227 | 64% {
228 | opacity: 1;
229 | }
230 | 69% {
231 | opacity: 1;
232 | }
233 | 75% {
234 | opacity: 0.65;
235 | }
236 | 82% {
237 | opacity: 0.35;
238 | }
239 | 90% {
240 | opacity: 0.15;
241 | }
242 | 100% {
243 | opacity: 0;
244 | }
245 | }
246 | }
247 |
248 | &.right-margin {
249 | margin-right: 5px;
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/web/src/data/offers.ts:
--------------------------------------------------------------------------------
1 | import OpenLocationCode from "open-location-code-typescript";
2 | import { Bounds, Point } from "pigeon-maps";
3 | import stringHash from "string-hash";
4 |
5 | import { ConnectionConfig, Exec } from "@/data/client";
6 | import { compileInsert, InsertStatement } from "@/data/sqlgen";
7 | import { boundsToWKTPolygon } from "@/geo";
8 | import {
9 | randomChoice,
10 | randomFloatInRange,
11 | randomIntegerInRange,
12 | randomVendor,
13 | Vendor,
14 | } from "@/rand";
15 | import VENDORS from "@/static-data/vendors.json";
16 |
17 | export const DEFAULT_CITY = {
18 | id: 120658,
19 | name: "New York City",
20 | lonlat: <[number, number]>[-74.00597003, 40.71427003],
21 | diameter: 0.04,
22 | };
23 |
24 | const MAX_OFFERS_PER_BATCH = 500;
25 |
26 | export type CityConfig = {
27 | id: number;
28 | name: string;
29 | lonlat: Point;
30 | diameter: number;
31 | };
32 |
33 | export const createCity = (config: ConnectionConfig, city: CityConfig) =>
34 | Exec(
35 | config,
36 | `
37 | INSERT INTO cities (city_id, city_name, center, diameter)
38 | VALUES (?, ?, ?, ?)
39 | ON DUPLICATE KEY UPDATE
40 | city_name = VALUES(city_name),
41 | center = VALUES(center),
42 | diameter = VALUES(diameter)
43 | `,
44 | city.id,
45 | city.name,
46 | `POINT(${city.lonlat[0]} ${city.lonlat[1]})`,
47 | city.diameter
48 | );
49 |
50 | export const removeCity = (config: ConnectionConfig, cityId: number) =>
51 | Exec(config, "DELETE FROM cities WHERE city_id = ?", cityId);
52 |
53 | export const SegmentKinds = ["olc_8", "purchase", "request"] as const;
54 | export type SegmentKind = (typeof SegmentKinds)[number];
55 |
56 | export const SegmentIntervals = [
57 | "minute",
58 | "hour",
59 | "day",
60 | "week",
61 | "month",
62 | ];
63 | export type SegmentInterval = (typeof SegmentIntervals)[number];
64 |
65 | export type Segment = {
66 | interval: SegmentInterval;
67 | kind: SegmentKind;
68 | value: string;
69 | };
70 |
71 | export const segmentId = ({ interval, kind, value }: Segment) =>
72 | stringHash(`${interval}-${kind}-${value}`);
73 |
74 | export const createSegments = async (
75 | config: ConnectionConfig,
76 | segments: Array
77 | ) => {
78 | const { sql, params } = compileInsert({
79 | table: "segments",
80 | options: { replace: true },
81 | columns: ["segment_id", "valid_interval", "filter_kind", "filter_value"],
82 | tuples: segments.map((segment) => [
83 | segmentId(segment),
84 | segment.interval,
85 | segment.kind,
86 | segment.value,
87 | ]),
88 | });
89 |
90 | await Exec(config, sql, ...params);
91 | };
92 |
93 | export type Offer = {
94 | customer: string;
95 | segments: Array;
96 |
97 | // should be a WKT polygon
98 | notificationZone: string;
99 |
100 | notificationContent: string;
101 | notificationTarget: string;
102 | maximumBidCents: number;
103 | };
104 |
105 | export const createOffers = async (
106 | config: ConnectionConfig,
107 | offers: Array
108 | ) => {
109 | const stmt: InsertStatement = {
110 | table: "offers",
111 | options: { replace: true },
112 | columns: [
113 | "customer",
114 | "notification_zone",
115 | "segment_ids",
116 | "notification_content",
117 | "notification_target",
118 | "maximum_bid_cents",
119 | ],
120 | tuples: [],
121 | };
122 |
123 | let numOffers = 0;
124 | let segments: Array = [];
125 |
126 | const commitBatch = async () => {
127 | const { sql, params } = compileInsert(stmt);
128 | await Promise.all([
129 | Exec(config, sql, ...params),
130 | createSegments(config, segments),
131 | ]);
132 |
133 | stmt.tuples = [];
134 | segments = [];
135 | numOffers = 0;
136 | };
137 |
138 | for (const offer of offers) {
139 | stmt.tuples.push([
140 | offer.customer,
141 | offer.notificationZone,
142 | JSON.stringify(offer.segments.map(segmentId)),
143 | offer.notificationContent,
144 | offer.notificationTarget,
145 | offer.maximumBidCents,
146 | ]);
147 |
148 | numOffers++;
149 | segments = segments.concat(offer.segments);
150 |
151 | if (numOffers >= MAX_OFFERS_PER_BATCH) {
152 | await commitBatch();
153 | }
154 | }
155 |
156 | if (numOffers > 0) {
157 | await commitBatch();
158 | }
159 | };
160 |
161 | const randomSegmentKind = () => randomChoice(SegmentKinds);
162 | const randomSegmentInterval = () => randomChoice(SegmentIntervals);
163 |
164 | const vendorDomain = ({ vendor, tld }: (typeof VENDORS)[number]) =>
165 | `${vendor.toLowerCase()}.${tld}`;
166 |
167 | const randomPointInCity = (city: CityConfig): Point => {
168 | const [lon, lat] = city.lonlat;
169 | const radius = city.diameter / 2;
170 | const [minLon, maxLon] = [lon - radius, lon + radius];
171 | const [minLat, maxLat] = [lat - radius, lat + radius];
172 | return [
173 | randomFloatInRange(minLon, maxLon),
174 | randomFloatInRange(minLat, maxLat),
175 | ];
176 | };
177 |
178 | export const randomSegment = (city: CityConfig, vendor: Vendor): Segment => {
179 | const kind = randomSegmentKind();
180 | const interval = randomSegmentInterval();
181 | switch (kind) {
182 | case "olc_8": {
183 | const [lon, lat] = randomPointInCity(city);
184 | const olc = OpenLocationCode.encode(lat, lon, 8).substring(0, 8);
185 | return {
186 | kind,
187 | interval,
188 | value: olc,
189 | };
190 | }
191 |
192 | case "purchase":
193 | return {
194 | kind,
195 | interval,
196 | value: vendor.vendor,
197 | };
198 |
199 | case "request":
200 | return {
201 | kind,
202 | interval,
203 | value: vendorDomain(vendor),
204 | };
205 | }
206 | };
207 |
208 | export const randomOffer = (city: CityConfig): Offer => {
209 | const numSegments = randomIntegerInRange(1, 3);
210 | const vendor = randomVendor();
211 |
212 | const segments = Array.from({ length: numSegments }, () =>
213 | randomSegment(city, vendor)
214 | );
215 |
216 | const domain = vendorDomain(vendor);
217 | const pctOff = randomIntegerInRange(10, 50);
218 | const notificationContent = `${pctOff}% off at ${vendor.vendor}`;
219 | const notificationTarget = domain;
220 |
221 | const [lon, lat] = randomPointInCity(city);
222 | const olc = OpenLocationCode.encode(lat, lon, 8);
223 | const area = OpenLocationCode.decode(olc);
224 | const bounds = {
225 | ne: [area.latitudeHi, area.longitudeHi],
226 | sw: [area.latitudeLo, area.longitudeLo],
227 | };
228 |
229 | return {
230 | customer: vendor.vendor,
231 | segments,
232 | notificationZone: boundsToWKTPolygon(bounds),
233 | notificationContent,
234 | notificationTarget,
235 | maximumBidCents: randomIntegerInRange(2, 15),
236 | };
237 | };
238 |
239 | export const randomOffers = (city: CityConfig, numOffers: number) =>
240 | Array.from({ length: numOffers }, () => randomOffer(city));
241 |
--------------------------------------------------------------------------------
/web/src/components/navBar/Nav.tsx:
--------------------------------------------------------------------------------
1 | import { CloseIcon, HamburgerIcon, MoonIcon, SunIcon } from "@chakra-ui/icons";
2 | import {
3 | Avatar,
4 | Box,
5 | Container,
6 | Flex,
7 | Heading,
8 | HStack,
9 | Icon,
10 | IconButton,
11 | Link,
12 | Menu,
13 | MenuButton,
14 | MenuItem,
15 | MenuList,
16 | Stack,
17 | useColorMode,
18 | useColorModeValue,
19 | useDisclosure,
20 | useMediaQuery,
21 | Wrap,
22 | WrapItem,
23 | } from "@chakra-ui/react";
24 | import * as React from "react";
25 | import {
26 | BsBarChart,
27 | BsFillBarChartFill,
28 | BsGear,
29 | BsGearFill,
30 | BsMap,
31 | BsMapFill,
32 | BsShare,
33 | BsShareFill,
34 | } from "react-icons/bs";
35 | import { ReactElement } from "react-markdown/lib/react-markdown";
36 | import { NavLink as RouterLink } from "react-router-dom";
37 |
38 | import SingleStoreLogoDark from "@/assets/singlestore-logo-dark.svg";
39 | import SinglestoreLogo from "@/assets/singlestore-logo-filled-sm.svg";
40 | import { GithubStargazer } from "@/components/GithubButtons";
41 | import { LinkedinIconButton, TwitterIconButton } from "@/components/IconLinks";
42 |
43 | import { SimulatorButton } from "../EnableSimulatorButton";
44 |
45 | export const SinglestoreBrandLogo = () => {
46 | const [isSmallScreen] = useMediaQuery("(max-width: 640px)");
47 |
48 | return (
49 |
50 |
51 |
52 |
59 |
60 |
61 |
62 | {isSmallScreen ? "Martech" : "Real-Time Digital Marketing"}
63 |
64 |
65 | );
66 | };
67 |
68 | export const NavTools = () => {
69 | const { toggleColorMode } = useColorMode();
70 | const { colorMode } = useColorMode();
71 |
72 | let themeModeIcon = ;
73 | if (colorMode === "dark") {
74 | themeModeIcon = ;
75 | }
76 |
77 | const handleLinkRedirect = (
78 | e: React.MouseEvent
79 | ) => {
80 | window.open(e.currentTarget.value, "_blank");
81 | };
82 |
83 | return (
84 |
85 |
108 | {themeModeIcon}
109 |
110 |
115 |
116 | );
117 | };
118 |
119 | export const Nav = () => {
120 | const handleNavMenu = useDisclosure();
121 | const [isSmallScreen] = useMediaQuery("(max-width: 640px)");
122 |
123 | let NavButtonIcon = ;
124 | let navClickAction = handleNavMenu.onOpen;
125 | if (handleNavMenu.isOpen) {
126 | NavButtonIcon = ;
127 | navClickAction = handleNavMenu.onClose;
128 | }
129 |
130 | const NavLinkActiveButtonStyle = {
131 | background: useColorModeValue("#4F34C7", "#CCC3F9"),
132 | color: useColorModeValue("#FFFFFF", "#2F206E"),
133 | };
134 |
135 | const LinksInternalComponent = ({
136 | NavLinkTitle,
137 | IconElement,
138 | }: {
139 | NavLinkTitle: string;
140 | IconElement: ReactElement;
141 | }) => {
142 | return (
143 |
144 | {NavLinkTitle}
145 |
146 | {IconElement}
147 |
148 |
149 | );
150 | };
151 |
152 | const NavLinkComponent = ({
153 | NavLinkTitle,
154 | IconElement,
155 | to,
156 | }: {
157 | NavLinkTitle: string;
158 | IconElement: ReactElement;
159 | to: string;
160 | }) => {
161 | return (
162 |
172 |
176 |
177 | );
178 | };
179 |
180 | const links = (
181 | <>
182 | }
186 | />
187 |
197 | }
198 | />
199 | }
203 | />
204 | >
205 | );
206 |
207 | const renderHamburgerNavMenu = () => {
208 | if (handleNavMenu.isOpen) {
209 | return (
210 |
211 |
212 | {links}
213 |
214 |
215 | );
216 | }
217 | return null;
218 | };
219 |
220 | return (
221 |
233 |
237 |
243 |
250 |
251 |
258 | {links}
259 |
260 |
261 |
262 | {renderHamburgerNavMenu()}
263 |
264 |
265 | );
266 | };
267 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SingleStore demo: Realtime Digital Marketing
2 |
3 | **Attention**: The code in this repository is intended for experimental use only and is not fully tested, documented, or supported by SingleStore. Visit the [SingleStore Forums](https://www.singlestore.com/forum/) to ask questions about this repository.
4 |
5 |
6 | - [Code tour](#code-tour)
7 | - [Data simulator](#data-simulator)
8 | - [`./gen`](#gen)
9 | - [`./output`](#output)
10 | - [Stored Procedures](#stored-procedures)
11 | - [Web application](#web-application)
12 | - [Quickstart: Docker Image](#quickstart-docker-image)
13 | - [Quickstart: SingleStore Managed Service](#quickstart-singlestore-managed-service)
14 | - [Running the web interface locally](#running-the-web-interface-locally)
15 | - [Manually running the demo in pure SQL](#manually-running-the-demo-in-pure-sql)
16 |
17 | This application is a demo of how to use [SingleStore][singlestore] to serve ads to users based on their behavior and realtime location. The demo is based on location, purchase, and request history from millions of simulated subscribers for a hypothetical service company.
18 |
19 | This application runs entirely within the web-browser and can be accessed publicly at https://digital-marketing.labs.singlestore.com
20 |
21 | 
22 |
23 | If you have any questions or issues, please file an issue on the [GitHub repo][gh-issues] or our [forums][forums].
24 |
25 | [singlestore]: https://www.singlestore.com
26 | [gh-issues]: https://github.com/singlestore-labs/demo-realtime-digital-marketing/issues
27 | [forums]: https://www.singlestore.com/forum/
28 |
29 | # Code tour
30 |
31 | This repository contains a data simulation tool written in go as well as a web application written in typescript. Refer to the following file tree for an overview of the code.
32 |
33 | ```
34 | .
35 | ├── cmd
36 | │ └── simulator (data simulation tool entrypoint)
37 | ├── data
38 | │ ├── cities.ndjson (coordinates of many cities worldwide)
39 | │ └── vendors.json (randomly generated vendors)
40 | ├── gen (go package for data generation)
41 | ├── output (go package for writing Parquet and JSON files out to the cloud)
42 | ├── README.md (this file!)
43 | ├── sql (all of the SQL data definition language (DDL) used in the demo)
44 | ├── util (go utility package)
45 | └── web (the web application)
46 | └── src
47 | ├── components (React components)
48 | ├── data (React hooks and all networking code)
49 | │ ├── client.ts (HTTP client for querying SingleStore)
50 | │ ├── queries.ts (SQL queries)
51 | │ ├── recoil.ts (Recoil atoms and selectors)
52 | └── main.tsx (web application entrypoint)
53 | ```
54 |
55 | # Data simulator
56 |
57 | The simulator is written in Golang. Internally it is modular, allowing it to output data in multiple formats to different storage providers. The recommended production outputter is S3/Parquet.
58 |
59 | The simulator can be configured using command line flags. By default it will write to `/tmp/subscriber-sim` (will error if this directory is missing). You can run it using default settings like so:
60 |
61 | ```bash
62 | mkdir -p /tmp/subscriber-sim
63 | go run cmd/simulator/main.go
64 | ```
65 |
66 | After running this command using default settings a bunch of JSON files will be written to `/tmp/subscriber-sim`. Inspect some of those files to get an idea of what the generated data looks like.
67 |
68 | ## `./gen`
69 |
70 | The `gen` package is responsible for generating the data. It has a driver which leverages many concurrent goroutines to iterate through all of the subscribers as fast as possible.
71 |
72 | Data generated by the simulator is designed to be loaded repeatedly to simulate device streams coming from many different locations over time. To help achieve this, timestamps are not included in the generated data and location is output as coordinates on the unit circle (centered at `[0,0]`)
73 |
74 | ## `./output`
75 |
76 | The `output` package is responsible for encoding and uploading the data to a target. The primary interface it exposes is `output.Writer`. The output package supports the following encodings/output targets:
77 |
78 | - Encodings
79 | - Parquet
80 | - JSON
81 | - Targets
82 | - Local filesystem
83 | - Anything supported by https://gocloud.dev/blob (e.g. S3)
84 |
85 | # Stored Procedures
86 |
87 | This demo makes extensive use of stored procedures which contain all of the logic required to load the data, segment users, and match subscribers to offers. You can find all of procedure code in [./sql/procedures.sql](sql/procedures.sql). Many procedures also depend on views which are all defined in [./sql/schema.sql](./sql/schema.sql).
88 |
89 | # Web application
90 |
91 | The [user interface][demo] is implemented as a single-page web application which runs entirely in the browser. The interface connects to any SingleStore cluster which has the [Data API][data-api] enabled. To get started quickly, we recommend either using our [Docker image][ciab] or the [SingleStore Managed Service][portal].
92 |
93 | ## Quickstart: Docker Image
94 |
95 | **This will not work on a Mac M1 or ARM hardware**
96 |
97 | 1. [Sign up][try-free] for a free SingleStore license. This allows you to run up to 4 nodes up to 32 gigs each for free. Grab your license key from [SingleStore portal][portal] and set it as an environment variable.
98 |
99 | ```bash
100 | export SINGLESTORE_LICENSE="singlestore license"
101 | ```
102 |
103 | 2. Start a SingleStore [cluster-in-a-box][ciab] using Docker:
104 |
105 | ```bash
106 | docker run -it \
107 | --name ciab \
108 | -e LICENSE_KEY=${SINGLESTORE_LICENSE} \
109 | -e ROOT_PASSWORD=test \
110 | -e HTTP_API=on \
111 | -p 3306:3306 -p 9000:9000 -p 8080:8080 \
112 | singlestore/cluster-in-a-box
113 | docker start ciab
114 | ```
115 |
116 | 3. Open the [Digital Marketing demo][demo] in Chrome or Firefox
117 | 4. Plug in the connection details:
118 |
119 | | Key | Value |
120 | | ----------- | --------------------- |
121 | | Host & Port | http://localhost:9000 |
122 | | Username | root |
123 | | Password | test |
124 |
125 | ## Quickstart: SingleStore Managed Service
126 |
127 | 1. [Sign up][try-free] for $500 in free managed service credits.
128 | 2. Create a S-00 sized cluster in [the portal][portal]
129 | 3. Open the [Digital Marketing demo][demo] in Chrome or Firefox
130 | 4. Plug in the connection details (replacing placeholders as needed):
131 |
132 | | Key | Value |
133 | | ----------- | ------------------------------ |
134 | | Host & Port | https://CLUSTER_CONNECTION_URL |
135 | | Username | admin |
136 | | Password | CLUSTER_ADMIN_PASSWORD |
137 |
138 | [try-free]: https://www.singlestore.com/try-free/
139 | [demo]: https://digital-marketing.labs.singlestore.com
140 | [data-api]: https://docs.singlestore.com/managed-service/en/reference/data-api.html
141 | [ciab]: https://github.com/memsql/deployment-docker
142 | [portal]: https://portal.singlestore.com/
143 |
144 | # Running the web interface locally
145 |
146 | Rather than using https://digital-marketing.labs.singlestore.com it's possible to run the web interface locally via Docker. You can do this like so:
147 |
148 | ```bash
149 | docker run -d --name demo -p 3000:3000 ghcr.io/singlestore-labs/demo-realtime-digital-marketing
150 | ```
151 |
152 | After running the above command the web interface will be running at http://localhost:3000.
153 |
154 | # Manually running the demo in pure SQL
155 |
156 | This entire demo can be run standalone on any SingleStore cluster without needing the Web UI. To do this, run all of the SQL scripts in the [./sql](./sql) folder in the following order:
157 |
158 | * functions.sql
159 | * schema.sql
160 | * procedures.sql
161 | * seed.sql
162 | * pipelines.sql (requires variable replacement, see below)
163 |
164 | We can do this on the command line like so:
165 |
166 | ```bash
167 | mysql -u root -h 172.17.0.3 -ptest -e "create database martech"
168 | mysql -u root -h 172.17.0.3 -ptest martech < sql/functions.sql
169 | mysql -u root -h 172.17.0.3 -ptest martech < sql/schema.sql
170 | mysql -u root -h 172.17.0.3 -ptest martech < sql/procedures.sql
171 | mysql -u root -h 172.17.0.3 -ptest martech < sql/seed.sql
172 | sed 's/${PARTITIONS}/2/;s/${SCALE_FACTOR}/v2\/100k-2p/' pipelines.sql | mysql -u root -h 172.17.0.3 -ptest martech
173 | ```
174 |
175 | Note that we are replacing the PARTITIONS and SCALE_FACTOR variables in pipelines.sql with acceptable values. See [scalefactors.ts](./web/src/scalefactors.ts) for additional options.
176 |
--------------------------------------------------------------------------------
/sql/schema.sql:
--------------------------------------------------------------------------------
1 | create rowstore table if not exists worldcities (
2 | city_id BIGINT NOT NULL PRIMARY KEY,
3 | city_name TEXT NOT NULL,
4 | center GEOGRAPHYPOINT NOT NULL,
5 |
6 | INDEX (center)
7 | );
8 |
9 | create rowstore table if not exists sessions (
10 | session_id TEXT NOT NULL,
11 | is_controller BOOLEAN NOT NULL DEFAULT FALSE,
12 | expires_at DATETIME(6) NOT NULL,
13 |
14 | PRIMARY KEY (session_id)
15 | );
16 |
17 | create rowstore reference table if not exists cities (
18 | city_id BIGINT NOT NULL PRIMARY KEY,
19 | city_name TEXT NOT NULL,
20 | center GEOGRAPHYPOINT NOT NULL,
21 | diameter DOUBLE
22 | );
23 |
24 | create rowstore table if not exists subscribers (
25 | city_id BIGINT NOT NULL,
26 | subscriber_id BIGINT NOT NULL,
27 | current_location GEOGRAPHYPOINT NOT NULL,
28 |
29 | PRIMARY KEY (city_id, subscriber_id),
30 | INDEX (current_location)
31 | );
32 |
33 | create rowstore table if not exists subscribers_last_notification (
34 | city_id BIGINT NOT NULL,
35 | subscriber_id BIGINT NOT NULL,
36 | last_notification DATETIME(6),
37 |
38 | PRIMARY KEY (city_id, subscriber_id),
39 | INDEX (last_notification)
40 | );
41 |
42 | create table if not exists locations (
43 | city_id BIGINT NOT NULL,
44 | subscriber_id BIGINT NOT NULL,
45 | ts DATETIME(6) NOT NULL SERIES TIMESTAMP,
46 | lonlat GEOGRAPHYPOINT NOT NULL,
47 |
48 | -- open location code length 8 (275m resolution)
49 | olc_8 TEXT NOT NULL,
50 |
51 | SHARD KEY (city_id, subscriber_id),
52 | SORT KEY (ts),
53 |
54 | KEY (city_id, subscriber_id) USING HASH,
55 | KEY (olc_8) USING HASH
56 | );
57 |
58 | create table if not exists requests (
59 | city_id BIGINT NOT NULL,
60 | subscriber_id BIGINT NOT NULL,
61 | ts DATETIME(6) NOT NULL SERIES TIMESTAMP,
62 | domain TEXT NOT NULL,
63 |
64 | SHARD KEY (city_id, subscriber_id),
65 | SORT KEY (ts),
66 | KEY (domain) USING HASH
67 | );
68 |
69 | create table if not exists purchases (
70 | city_id BIGINT NOT NULL,
71 | subscriber_id BIGINT NOT NULL,
72 | ts DATETIME(6) NOT NULL SERIES TIMESTAMP,
73 | vendor TEXT NOT NULL,
74 |
75 | SHARD KEY (city_id, subscriber_id),
76 | SORT KEY (ts),
77 | KEY (vendor) USING HASH
78 | );
79 |
80 | create rowstore reference table if not exists offers (
81 | offer_id BIGINT NOT NULL AUTO_INCREMENT,
82 | customer TEXT NOT NULL,
83 | enabled BOOLEAN NOT NULL DEFAULT TRUE,
84 |
85 | notification_zone GEOGRAPHY NOT NULL,
86 | segment_ids JSON NOT NULL,
87 |
88 | notification_content TEXT NOT NULL,
89 | notification_target TEXT NOT NULL,
90 |
91 | maximum_bid_cents BIGINT NOT NULL,
92 |
93 | PRIMARY KEY (offer_id),
94 | INDEX (notification_zone),
95 | INDEX (customer),
96 | INDEX (notification_target)
97 | );
98 |
99 | create table if not exists notifications (
100 | ts DATETIME(6) NOT NULL SERIES TIMESTAMP,
101 |
102 | city_id BIGINT NOT NULL,
103 | subscriber_id BIGINT NOT NULL,
104 |
105 | offer_id BIGINT NOT NULL,
106 | cost_cents BIGINT NOT NULL,
107 | lonlat GEOGRAPHYPOINT NOT NULL,
108 |
109 | SHARD KEY (city_id, subscriber_id),
110 | SORT KEY (ts)
111 | );
112 |
113 | create rowstore reference table if not exists segments (
114 | segment_id BIGINT NOT NULL,
115 |
116 | valid_interval ENUM ("minute", "hour", "day", "week", "month") NOT NULL,
117 | filter_kind ENUM ("olc_8", "request", "purchase") NOT NULL,
118 | filter_value TEXT NOT NULL,
119 |
120 | PRIMARY KEY (segment_id),
121 | UNIQUE KEY (valid_interval, filter_kind, filter_value),
122 | KEY (filter_kind, filter_value)
123 | );
124 |
125 | create rowstore table if not exists subscriber_segments (
126 | city_id BIGINT NOT NULL,
127 | subscriber_id BIGINT NOT NULL,
128 | segment_id BIGINT NOT NULL,
129 |
130 | expires_at DATETIME(6) NOT NULL,
131 |
132 | PRIMARY KEY (city_id, subscriber_id, segment_id),
133 | SHARD KEY (city_id, subscriber_id)
134 | );
135 |
136 | CREATE OR REPLACE FUNCTION match_offers_to_subscribers(
137 | _interval ENUM("second", "minute", "hour", "day", "week", "month")
138 | ) RETURNS TABLE AS RETURN (
139 | WITH
140 | phase_1 as (
141 | SELECT offers.*, subscribers.*
142 | FROM
143 | offers,
144 | subscribers
145 | -- grab last notification time for each subscriber
146 | -- with(table_convert_subselect=true) forces a hash join
147 | LEFT JOIN subscribers_last_notification with(table_convert_subselect=true) ON (
148 | subscribers.city_id = subscribers_last_notification.city_id
149 | AND subscribers.subscriber_id = subscribers_last_notification.subscriber_id
150 | )
151 | WHERE
152 | offers.enabled = TRUE
153 |
154 | -- match offers to subscribers based on current location
155 | AND geography_contains(offers.notification_zone, subscribers.current_location)
156 |
157 | -- ensure we don't spam subscribers
158 | AND (
159 | subscribers_last_notification.last_notification IS NULL
160 | OR subscribers_last_notification.last_notification < date_sub_dynamic(NOW(), _interval)
161 | )
162 |
163 | -- only match (offer, subscriber) pairs such that
164 | -- there is no matching notification in the last minute
165 | AND NOT EXISTS (
166 | SELECT * FROM notifications n
167 | WHERE
168 | ts > date_sub_dynamic(NOW(), _interval)
169 | AND offers.offer_id = n.offer_id
170 | AND subscribers.city_id = n.city_id
171 | AND subscribers.subscriber_id = n.subscriber_id
172 | )
173 | ),
174 | phase_2 as (
175 | select
176 | phase_1.*,
177 | row_number() over (
178 | partition by phase_1.offer_id, phase_1.city_id, phase_1.subscriber_id
179 | ) as num_matching_segments
180 | from phase_1
181 | JOIN TABLE(JSON_TO_ARRAY(phase_1.segment_ids)) AS segment_ids
182 | LEFT JOIN subscriber_segments segment ON (
183 | phase_1.city_id = segment.city_id
184 | AND phase_1.subscriber_id = segment.subscriber_id
185 | AND (segment_ids.table_col :> BIGINT) = segment.segment_id
186 | )
187 | )
188 | select
189 | city_id,
190 | subscriber_id,
191 | -- window to find one offer with highest bid per subscriber
192 | last_value(offer_id) over window_best_offer as best_offer_id,
193 | last_value(maximum_bid_cents) over window_best_offer as cost_cents,
194 | current_location
195 | from phase_2
196 | where json_length(segment_ids) = num_matching_segments
197 | group by city_id, subscriber_id
198 | WINDOW window_best_offer as (
199 | partition by city_id, subscriber_id
200 | order by maximum_bid_cents asc
201 | )
202 | );
203 |
204 | CREATE OR REPLACE FUNCTION dynamic_subscriber_segments_locations(
205 | _since DATETIME(6),
206 | _until DATETIME(6)
207 | ) RETURNS TABLE AS RETURN (
208 | SELECT
209 | city_id, subscriber_id, segment_id,
210 | MAX(date_add_dynamic(ts, segments.valid_interval)) AS expires_at
211 | FROM segments, locations
212 | WHERE
213 | segments.filter_kind = "olc_8"
214 | AND segments.filter_value = locations.olc_8
215 | AND ts >= date_sub_dynamic(NOW(6), segments.valid_interval)
216 | AND ts >= _since
217 | AND ts < _until
218 | GROUP BY city_id, subscriber_id, segment_id
219 | );
220 |
221 | CREATE OR REPLACE FUNCTION dynamic_subscriber_segments_requests(
222 | _since DATETIME(6),
223 | _until DATETIME(6)
224 | ) RETURNS TABLE AS RETURN (
225 | SELECT
226 | city_id, subscriber_id, segment_id,
227 | MAX(date_add_dynamic(ts, segments.valid_interval)) AS expires_at
228 | FROM segments, requests
229 | WHERE
230 | segments.filter_kind = "request"
231 | AND segments.filter_value = requests.domain
232 | AND ts >= date_sub_dynamic(NOW(6), segments.valid_interval)
233 | AND ts >= _since
234 | AND ts < _until
235 | GROUP BY city_id, subscriber_id, segment_id
236 | );
237 |
238 | CREATE OR REPLACE FUNCTION dynamic_subscriber_segments_purchases(
239 | _since DATETIME(6),
240 | _until DATETIME(6)
241 | ) RETURNS TABLE AS RETURN (
242 | SELECT
243 | city_id, subscriber_id, segment_id,
244 | MAX(date_add_dynamic(ts, segments.valid_interval)) AS expires_at
245 | FROM segments, purchases
246 | WHERE
247 | segments.filter_kind = "purchase"
248 | AND segments.filter_value = purchases.vendor
249 | AND ts >= date_sub_dynamic(NOW(6), segments.valid_interval)
250 | AND ts >= _since
251 | AND ts < _until
252 | GROUP BY city_id, subscriber_id, segment_id
253 | );
254 |
255 | CREATE OR REPLACE FUNCTION dynamic_subscriber_segments(
256 | _since DATETIME(6),
257 | _until DATETIME(6)
258 | ) RETURNS TABLE AS RETURN (
259 | SELECT * FROM dynamic_subscriber_segments_locations(_since, _until)
260 | UNION ALL
261 | SELECT * FROM dynamic_subscriber_segments_requests(_since, _until)
262 | UNION ALL
263 | SELECT * FROM dynamic_subscriber_segments_purchases(_since, _until)
264 | );
--------------------------------------------------------------------------------