├── .gitattributes
├── .github
└── workflows
│ ├── conventional-commits.yml
│ ├── docs-and-deploy.yml
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .prettierrc
├── .vscode
└── settings.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── Tiltfile
├── admin
├── .eslintrc.json
├── .gitignore
├── Dockerfile.dev
├── README.md
├── app
│ ├── cli-auth
│ │ └── page.tsx
│ ├── clusters
│ │ ├── CreateClusterButton.tsx
│ │ ├── [clusterId]
│ │ │ ├── ClusterLiveTables.tsx
│ │ │ ├── ClusterSettings.tsx
│ │ │ ├── Navigation.tsx
│ │ │ ├── SecretKeyReveal.tsx
│ │ │ ├── activity
│ │ │ │ ├── deployment
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── advanced
│ │ │ │ └── page.tsx
│ │ │ ├── client-libraries
│ │ │ │ └── page.tsx
│ │ │ ├── helpers.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── monitoring
│ │ │ │ └── page.tsx
│ │ │ ├── overview
│ │ │ │ ├── ActivityChart.tsx
│ │ │ │ ├── CopyButton.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── services
│ │ │ │ ├── ServiceSummary.tsx
│ │ │ │ ├── [serviceName]
│ │ │ │ │ ├── ServiceDeployments.tsx
│ │ │ │ │ ├── ServiceLiveTables.tsx
│ │ │ │ │ ├── ServiceMetrics.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ └── tasks
│ │ │ │ └── page.tsx
│ │ ├── fill-dates.ts
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── logo.png
│ └── page.tsx
├── client
│ ├── client.ts
│ └── contract.ts
├── components.json
├── components
│ ├── composite
│ │ └── activity.tsx
│ └── ui
│ │ ├── DataTable.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── clipboard-button.tsx
│ │ ├── collapsible.tsx
│ │ ├── drawer.tsx
│ │ ├── label.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── scroll-area.tsx
│ │ ├── switch.tsx
│ │ └── table.tsx
├── kubernetes.dev.yaml
├── lib
│ └── utils.ts
├── middleware.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── next.svg
│ └── vercel.svg
├── tailwind.config.js
├── tailwind.config.ts
└── tsconfig.json
├── assets
├── differential-cloud-2.png
├── differential-cloud.gif
└── image-3.png
├── cli
├── CHANGELOG.md
├── README.md
├── differential.local.json
├── package.json
├── src
│ ├── client
│ │ └── contract.ts
│ ├── commands
│ │ ├── auth.ts
│ │ ├── client-lib-publish.ts
│ │ ├── client-lib.ts
│ │ ├── clusters-create.ts
│ │ ├── clusters-info.ts
│ │ ├── clusters-list.ts
│ │ ├── clusters-open.ts
│ │ ├── clusters.ts
│ │ ├── context-info.ts
│ │ ├── context-set.ts
│ │ ├── context.ts
│ │ ├── deploy-create.ts
│ │ ├── deploy-info.ts
│ │ ├── deploy-list.ts
│ │ ├── deploy-logs.ts
│ │ ├── deploy.ts
│ │ ├── repl.ts
│ │ ├── services-register.ts
│ │ ├── services.ts
│ │ └── task.ts
│ ├── constants.ts
│ ├── index.ts
│ ├── lib
│ │ ├── auth.ts
│ │ ├── client.ts
│ │ ├── context.ts
│ │ ├── package.ts
│ │ ├── release.ts
│ │ ├── service-discovery.ts
│ │ └── upload.ts
│ └── utils.ts
└── tsconfig.json
├── control-plane
├── .dockerignore
├── .gitignore
├── CHANGELOG.md
├── Dockerfile
├── Dockerfile.dev
├── LICENSE
├── README.md
├── babel.config.js
├── drizzle
│ ├── 0000_odd_tattoo.sql
│ ├── 0001_tense_dreaming_celestial.sql
│ ├── 0002_steady_korath.sql
│ ├── 0003_living_captain_america.sql
│ ├── 0004_confused_goliath.sql
│ ├── 0005_overconfident_hex.sql
│ ├── 0006_ambiguous_wild_pack.sql
│ ├── 0007_icy_the_hand.sql
│ ├── 0008_omniscient_mongu.sql
│ ├── 0009_cuddly_medusa.sql
│ ├── 0010_curious_amphibian.sql
│ ├── 0011_thin_preak.sql
│ ├── 0012_manual_make.sql
│ ├── 0013_bitter_lily_hollister.sql
│ ├── 0014_kind_mercury.sql
│ ├── 0015_flimsy_microbe.sql
│ ├── 0016_unique_wallflower.sql
│ ├── 0017_purple_captain_stacy.sql
│ ├── 0018_spooky_sasquatch.sql
│ ├── 0019_early_silver_sable.sql
│ ├── 0020_third_lockjaw.sql
│ ├── 0021_brave_eternals.sql
│ ├── 0022_bizarre_princess_powerful.sql
│ ├── 0023_lazy_dakota_north.sql
│ ├── 0024_thin_black_bolt.sql
│ ├── 0025_hot_shinobi_shaw.sql
│ ├── 0026_freezing_clint_barton.sql
│ ├── 0027_lyrical_ken_ellis.sql
│ ├── 0028_parallel_karen_page.sql
│ ├── 0029_sad_thunderbird.sql
│ ├── 0030_omniscient_jamie_braddock.sql
│ ├── 0031_misty_slipstream.sql
│ ├── 0032_clumsy_gressill.sql
│ ├── 0033_high_komodo.sql
│ ├── 0034_real_big_bertha.sql
│ ├── 0035_eminent_fantastic_four.sql
│ ├── 0036_ambiguous_human_robot.sql
│ ├── 0037_empty_molecule_man.sql
│ ├── 0038_woozy_tiger_shark.sql
│ ├── 0039_puzzling_vampiro.sql
│ ├── 0040_mean_jazinda.sql
│ ├── 0041_youthful_steve_rogers.sql
│ ├── 0042_kind_lester.sql
│ ├── 0043_closed_weapon_omega.sql
│ ├── 0044_fancy_landau.sql
│ ├── 0045_icy_kid_colt.sql
│ ├── 0046_needy_brood.sql
│ ├── 0047_certain_penance.sql
│ ├── 0048_awesome_joystick.sql
│ ├── 0049_gray_ben_grimm.sql
│ ├── 0050_slimy_hulk.sql
│ ├── 0051_loving_phil_sheldon.sql
│ ├── 0052_swift_microbe.sql
│ ├── 0053_broken_zaran.sql
│ └── meta
│ │ ├── 0000_snapshot.json
│ │ ├── 0001_snapshot.json
│ │ ├── 0002_snapshot.json
│ │ ├── 0003_snapshot.json
│ │ ├── 0004_snapshot.json
│ │ ├── 0005_snapshot.json
│ │ ├── 0006_snapshot.json
│ │ ├── 0007_snapshot.json
│ │ ├── 0008_snapshot.json
│ │ ├── 0009_snapshot.json
│ │ ├── 0010_snapshot.json
│ │ ├── 0011_snapshot.json
│ │ ├── 0012_snapshot.json
│ │ ├── 0013_snapshot.json
│ │ ├── 0014_snapshot.json
│ │ ├── 0015_snapshot.json
│ │ ├── 0016_snapshot.json
│ │ ├── 0017_snapshot.json
│ │ ├── 0018_snapshot.json
│ │ ├── 0019_snapshot.json
│ │ ├── 0020_snapshot.json
│ │ ├── 0021_snapshot.json
│ │ ├── 0022_snapshot.json
│ │ ├── 0023_snapshot.json
│ │ ├── 0024_snapshot.json
│ │ ├── 0025_snapshot.json
│ │ ├── 0026_snapshot.json
│ │ ├── 0027_snapshot.json
│ │ ├── 0028_snapshot.json
│ │ ├── 0029_snapshot.json
│ │ ├── 0030_snapshot.json
│ │ ├── 0031_snapshot.json
│ │ ├── 0032_snapshot.json
│ │ ├── 0033_snapshot.json
│ │ ├── 0034_snapshot.json
│ │ ├── 0035_snapshot.json
│ │ ├── 0036_snapshot.json
│ │ ├── 0037_snapshot.json
│ │ ├── 0038_snapshot.json
│ │ ├── 0039_snapshot.json
│ │ ├── 0040_snapshot.json
│ │ ├── 0041_snapshot.json
│ │ ├── 0042_snapshot.json
│ │ ├── 0043_snapshot.json
│ │ ├── 0044_snapshot.json
│ │ ├── 0045_snapshot.json
│ │ ├── 0046_snapshot.json
│ │ ├── 0047_snapshot.json
│ │ ├── 0048_snapshot.json
│ │ ├── 0049_snapshot.json
│ │ ├── 0050_snapshot.json
│ │ ├── 0051_snapshot.json
│ │ ├── 0052_snapshot.json
│ │ ├── 0053_snapshot.json
│ │ └── _journal.json
├── fly.toml
├── internal.md
├── kubernetes.dev.yaml
├── media
│ └── logo.png
├── package-lock.json
├── package.json
├── postgres.dev.yaml
├── src
│ ├── index.ts
│ ├── modules
│ │ ├── admin.ts
│ │ ├── agents
│ │ │ └── agent.ts
│ │ ├── assets.test.ts
│ │ ├── assets.ts
│ │ ├── cluster-activity.ts
│ │ ├── cluster.ts
│ │ ├── contract.ts
│ │ ├── cron.ts
│ │ ├── data.ts
│ │ ├── deployment
│ │ │ ├── cfn-manager.test.ts
│ │ │ ├── cfn-manager.ts
│ │ │ ├── deployment-provider.ts
│ │ │ ├── deployment.test.ts
│ │ │ ├── deployment.ts
│ │ │ ├── lambda-cfn-provider.ts
│ │ │ ├── mock-deployment-provider.ts
│ │ │ └── scheduler.ts
│ │ ├── health.ts
│ │ ├── jobs
│ │ │ ├── create-job.ts
│ │ │ ├── job-metrics.ts
│ │ │ ├── jobs.test.ts
│ │ │ ├── jobs.ts
│ │ │ ├── persist-result.test.ts
│ │ │ └── persist-result.ts
│ │ ├── jwt.test.ts
│ │ ├── jwt.ts
│ │ ├── management.test.ts
│ │ ├── management.ts
│ │ ├── names.ts
│ │ ├── observability
│ │ │ ├── event-aggregation.test.ts
│ │ │ ├── event-aggregation.ts
│ │ │ └── events.ts
│ │ ├── packages
│ │ │ ├── client-lib.ts
│ │ │ ├── versioning.test.ts
│ │ │ └── versioning.ts
│ │ ├── predictor
│ │ │ ├── client.ts
│ │ │ ├── contract.ts
│ │ │ ├── predictor.test.ts
│ │ │ ├── predictor.ts
│ │ │ └── serialize-error.js
│ │ ├── router.test.ts
│ │ ├── router.ts
│ │ ├── routing-helpers.test.ts
│ │ ├── routing-helpers.ts
│ │ ├── s3.ts
│ │ ├── service-definitions.ts
│ │ ├── sns.test.ts
│ │ ├── sns.ts
│ │ ├── test
│ │ │ └── util.ts
│ │ └── util.ts
│ └── utilities
│ │ ├── env.ts
│ │ ├── errors.ts
│ │ ├── invariant.ts
│ │ ├── logger.ts
│ │ ├── migrate.ts
│ │ └── profiling.ts
└── tsconfig.json
├── docs
├── .github
│ └── workflows
│ │ └── retype-action.yml
├── 256.png
├── CNAME
├── Makefile
├── _includes
│ └── head.html
├── advanced
│ ├── calling-functions
│ │ ├── arguments-and-return-values.md
│ │ └── customizing-function-calls.md
│ ├── handling-failures
│ │ ├── compute-recovery.md
│ │ ├── failure-modes.md
│ │ └── how-functions-fail.md
│ ├── how-things-work
│ │ ├── architecture.md
│ │ ├── concepts.md
│ │ ├── image.png
│ │ ├── limitations.md
│ │ └── service-discovery.md
│ ├── index.yml
│ └── self-hosting.md
├── api
│ ├── README.md
│ └── classes
│ │ └── Differential.md
├── assets
│ └── image-1.png
├── deployments
│ ├── index.yml
│ └── managed-compute.md
├── getting-started
│ ├── index.yml
│ ├── quick-start.md
│ └── thinking.md
├── guides
│ ├── caching.md
│ ├── deduplicating.md
│ ├── end-to-end-encryption.md
│ ├── idempotency.md
│ ├── index.yml
│ ├── predictive-alerting.md
│ ├── predictive-retries.md
│ ├── recovering-from-infrastructure-stalls.md
│ └── shared-client-libraries.md
├── image.png
├── index.md
├── package.json
└── retype.yml
├── infrastructure
├── README.md
├── cfn.yaml
└── deployment-templates
│ └── lambda-cfn.yaml
├── lerna.json
├── load-tester
├── README.md
├── package-lock.json
├── package.json
├── run.sh
├── scripts
│ └── setup.sh
├── src
│ ├── commands
│ │ ├── basic.ts
│ │ ├── parallel-100.ts
│ │ ├── parallel-1000.ts
│ │ ├── parallel-10000.ts
│ │ └── parallel.ts
│ ├── d.ts
│ ├── services
│ │ └── executor.ts
│ └── t.ts
└── tsconfig.json
├── package-lock.json
├── package.json
└── sdk
├── Makefile
├── README.md
├── assets
└── logo.png
├── babel.config.js
├── docs
├── .nojekyll
├── README.md
└── modules.md
├── package-lock.json
├── package.json
├── src
├── Differential.test.ts
├── Differential.ts
├── call-config.ts
├── contract.ts
├── create-client.ts
├── errors.ts
├── events.test.ts
├── events.ts
├── index.ts
├── results-poller.ts
├── serialize-error.js
├── serialize.test.ts
├── serialize.ts
├── task-queue.test.ts
├── task-queue.ts
├── tests
│ ├── e2ee
│ │ ├── d.ts
│ │ ├── e2ee.test.ts
│ │ └── hello.ts
│ ├── errors
│ │ ├── animals.ts
│ │ ├── d.ts
│ │ └── errors.test.ts
│ ├── monolith
│ │ ├── d.ts
│ │ ├── db.ts
│ │ ├── differential-398308-5f1deaf67146.json
│ │ ├── expert.ts
│ │ ├── facade.ts
│ │ ├── monolith.test.ts
│ │ └── run.ts
│ └── utility
│ │ ├── caching.test.ts
│ │ ├── d.ts
│ │ ├── product.ts
│ │ └── retry.test.ts
├── types.ts
└── util.ts
├── tsconfig.json
└── typedoc.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | # git-crypt files containing secrets under R&D
2 | control-plane/src/modules/agents/** filter=git-crypt diff=git-crypt
3 | control-plane/kubernetes.dev.yaml filter=git-crypt diff=git-crypt
4 | sdk/src/tests/monolith/** filter=git-crypt diff=git-crypt
--------------------------------------------------------------------------------
/.github/workflows/conventional-commits.yml:
--------------------------------------------------------------------------------
1 | name: Check conventional commits
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | types: [opened, edited, reopened, synchronize, ready_for_review]
8 |
9 | jobs:
10 | commit-message-validation:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Commit message validation
15 | uses: amannn/action-semantic-pull-request@v5
16 | env:
17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/docs-and-deploy.yml:
--------------------------------------------------------------------------------
1 | name: Generate Docs and deploy
2 |
3 | on:
4 | workflow_run:
5 | workflows: ["Run Tests"]
6 | branches: [main]
7 | types:
8 | - completed
9 |
10 | jobs:
11 | docs:
12 | runs-on: ubuntu-latest
13 | if: ${{ github.event.workflow_run.conclusion == 'success' }}
14 |
15 | permissions:
16 | contents: write
17 |
18 | steps:
19 | - name: Checkout code
20 | uses: actions/checkout@v2
21 | with:
22 | fetch-depth: "0" # fetch all history for all tags and branches so that changeset-builder can generate changelogs
23 |
24 | - name: Set up Node.js
25 | uses: actions/setup-node@v2
26 | with:
27 | node-version: 20
28 | registry-url: "https://registry.npmjs.org"
29 | scope: "@differentialhq"
30 |
31 | - name: Install dependencies on root
32 | run: npm install
33 |
34 | - name: Configure Git User
35 | run: |
36 | git config --global user.name "Differential CI"
37 | git config --global user.email "ci@differential.dev"
38 |
39 | - name: Generate docs
40 | run: |
41 | make docs
42 | git add .
43 | git commit -m "chore: Update documentation [skip ci]" || true # ignore if there's nothing to commit
44 | git push origin main
45 |
46 | - uses: retypeapp/action-build@latest
47 | with:
48 | config: docs
49 |
50 | - uses: retypeapp/action-github-pages@latest
51 | with:
52 | update-branch: true
53 |
54 | deploy:
55 | name: deploy
56 | runs-on: ubuntu-latest
57 | if: ${{ github.event.workflow_run.conclusion == 'success' }}
58 | steps:
59 | - uses: actions/checkout@v3
60 | - uses: sliteteam/github-action-git-crypt-unlock@1.2.0
61 | env:
62 | GIT_CRYPT_KEY: ${{ secrets.GIT_CRYPT_KEY }}
63 | - uses: superfly/flyctl-actions/setup-flyctl@master
64 | - run: cd control-plane && flyctl deploy --remote-only
65 | env:
66 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
67 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Create Release
2 |
3 | on: workflow_dispatch
4 |
5 | jobs:
6 | create-release:
7 | runs-on: ubuntu-latest
8 |
9 | permissions:
10 | contents: write
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v2
15 | with:
16 | fetch-depth: "0" # fetch all history for all tags and branches so that changeset-builder can generate changelogs
17 |
18 | - name: Set up Node.js
19 | uses: actions/setup-node@v2
20 | with:
21 | node-version: 20
22 | registry-url: "https://registry.npmjs.org"
23 | scope: "@differentialhq"
24 |
25 | - name: Install dependencies on root
26 | run: npm install
27 |
28 | - name: Set DIFFERENTIAL_API_SECRET
29 | run: echo "DIFFERENTIAL_API_SECRET=$(curl https://api.differential.dev/demo/token)" >> $GITHUB_ENV
30 |
31 | - uses: sliteteam/github-action-git-crypt-unlock@1.2.0
32 | env:
33 | GIT_CRYPT_KEY: ${{ secrets.GIT_CRYPT_KEY }}
34 |
35 | - name: Run tests
36 | run: npx lerna run test
37 | env:
38 | DATABASE_URL: ${{ secrets.DATABASE_URL }}
39 | JWKS_URL: ${{ secrets.JWKS_URL }}
40 | PREDICTOR_API_URL: ${{ secrets.PREDICTOR_API_URL }}
41 |
42 | - name: Build packages
43 | run: npx lerna run build
44 |
45 | - name: Configure Git User
46 | run: |
47 | git config --global user.name "Differential CI"
48 | git config --global user.email "ci@differential.dev"
49 |
50 | - name: Version packages
51 | run: npx lerna version --conventional-commits --yes --no-private --no-push
52 | env:
53 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54 |
55 | # - name: Version packages
56 | # run: npx lerna version --conventional-commits --yes --no-private --create-release github
57 | # env:
58 | # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59 |
60 | # - name: Publish packages
61 | # run: npx lerna publish from-package --yes --no-private --registry https://registry.npmjs.org
62 | # env:
63 | # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
64 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 |
4 | on:
5 | push:
6 | branches:
7 | - main
8 | pull_request:
9 | branches:
10 | - main
11 |
12 | jobs:
13 | default:
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - name: Checkout code
18 | uses: actions/checkout@v2
19 | with:
20 | fetch-depth: "0" # fetch all history for all tags and branches so that changeset-builder can generate changelogs
21 |
22 | - name: Set up Node.js
23 | uses: actions/setup-node@v2
24 | with:
25 | node-version: 20
26 | cache: 'npm'
27 |
28 | - name: Install dependencies
29 | run: npm install
30 |
31 | # - name: Set DIFFERENTIAL_API_SECRET
32 | # run: echo "DIFFERENTIAL_API_SECRET=$(curl https://api.differential.dev/demo/token)" >> $GITHUB_ENV
33 |
34 | # - name: Run tests
35 | # run: npx lerna run test
36 | # env:
37 | # DATABASE_URL: ${{ secrets.DATABASE_URL }}
38 | # JWKS_URL: ${{ secrets.JWKS_URL }}
39 | # PREDICTOR_API_URL: ${{ secrets.PREDICTOR_API_URL }}
40 |
41 | # - uses: sliteteam/github-action-git-crypt-unlock@1.2.0
42 | # env:
43 | # GIT_CRYPT_KEY: ${{ secrets.GIT_CRYPT_KEY }}
44 |
45 | # - name: Test builds
46 | # run: npx lerna run build
47 |
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": false
6 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Differential
2 |
3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
4 |
5 | - Reporting a bug
6 | - Discussing the current state of the code
7 | - Submitting a fix
8 | - Proposing new features
9 |
10 | ## Setting up the development environment
11 |
12 | We use [tilt.dev](https://tilt.dev) to manage our development environment.
13 |
14 | 1. [Install](https://docs.tilt.dev/install.html) tilt on your machine.
15 | 2. Run `tilt up` in the root of the project.
16 |
17 | ## Running the admin console locally
18 |
19 | The admin console is a Next.js app using Clerk.dev for authentication. To run it locally, you'll need to add a `.env.local` file to the `admin` directory with some test credentials from Clerk.dev. You can sign up for a free account at [clerk.dev](https://clerk.dev).
20 |
21 | ```
22 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME
23 | CLERK_SECRET_KEY=sk_test_REPLACE_ME
24 | ```
25 |
26 | ## We Develop with Github
27 |
28 | We use github to host code, to track issues and feature requests, as well as accept pull requests.
29 |
30 | ## All Code Changes Happen Through Pull Requests
31 |
32 | Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests:
33 |
34 | 1. Fork the repo and create your branch from `main`.
35 | 2. If you've added code that should be tested, add tests.
36 | 3. If you've changed APIs, update the documentation.
37 | 4. Ensure the test suite passes.
38 | 5. Make sure your code lints.
39 | 6. Issue that pull request!
40 |
41 | ## Any contributions you make will be under the Apache-2.0 Software License
42 |
43 | In short, when you submit code changes, your submissions are understood to be under the same license that covers the project. Feel free to contact the maintainers if that's a concern.
44 |
45 | ## Report bugs using Github's [issues](https://github.com/differentialhq/differential/issues)
46 |
47 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/differentialhq/differential/issues/new).
48 |
49 | ## Write bug reports with detail, background, and sample code
50 |
51 | A few things to consider when writing a bug report:
52 |
53 | - A quick summary and/or background
54 | - Steps to reproduce
55 | - Be specific!
56 | - What you expected would happen
57 | - What actually happens
58 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
59 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: docs
2 |
3 | docs:
4 | @echo "Generating docs..."
5 |
6 | cd sdk && \
7 | npm run docs
8 |
9 | cd docs && \
10 | cp -r ../sdk/docs/* ./api && \
11 | rm ./api/modules.md
12 |
13 | @echo "Done"
--------------------------------------------------------------------------------
/Tiltfile:
--------------------------------------------------------------------------------
1 | k8s_yaml('control-plane/postgres.dev.yaml')
2 | k8s_resource('postgres', port_forwards=5432)
3 |
4 | k8s_yaml('control-plane/kubernetes.dev.yaml')
5 | k8s_resource('control-plane', port_forwards=[
6 | port_forward(4000, 4000, "api"),
7 | port_forward(9091, 9091, 'metrics')
8 | ], resource_deps=['postgres'])
9 |
10 | docker_build('control-plane-image', 'control-plane', dockerfile='control-plane/Dockerfile.dev', live_update=[
11 | sync('./control-plane/.env', '/app/.env'),
12 | sync('./control-plane/src', '/app/src'),
13 | sync('./control-plane/package.json', '/app/package.json'),
14 | sync('./control-plane/package-lock.json', '/app/package-lock.json'),
15 | run(
16 | 'npm install',
17 | trigger=['./control-plane/package.json', './control-plane/package-lock.json'],
18 | )
19 | ], network='host')
20 |
21 | k8s_yaml('admin/kubernetes.dev.yaml')
22 | k8s_resource('admin', port_forwards=['3000'], resource_deps=['control-plane'])
23 |
24 | docker_build('admin-image', 'admin', dockerfile='admin/Dockerfile.dev', live_update=[
25 | sync('./admin/client', '/app/client'),
26 | sync('./admin/app', '/app/app'),
27 | sync('./admin/components', '/app/components'),
28 | sync('./admin/lib', '/app/lib'),
29 | sync('./admin/package.json', '/app/package.json'),
30 | sync('./admin/package-lock.json', '/app/package-lock.json'),
31 | run(
32 | 'npm install',
33 | trigger=['./admin/package.json', './admin/package-lock.json'],
34 | )
35 | ], ignore='.next', network='host')
--------------------------------------------------------------------------------
/admin/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/admin/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/admin/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM node:20
2 |
3 | WORKDIR /app
4 |
5 | # copy package.json and package-lock.json
6 | COPY --link package.json package-lock.json ./
7 |
8 | # install dependencies
9 | RUN npm install
10 |
11 | # copy source code
12 | COPY --link . .
13 |
14 | RUN npm install
15 |
16 | ENTRYPOINT [ "npm", "run", "dev" ]
--------------------------------------------------------------------------------
/admin/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/admin/app/cli-auth/page.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs";
2 | import { redirect } from "next/navigation";
3 |
4 | export default async function CliAuth() {
5 | const { getToken } = await auth();
6 |
7 | const token = await getToken({
8 | template: "extended-cli-token",
9 | });
10 |
11 | if (!token) {
12 | return null;
13 | }
14 |
15 | const url = new URL("http://localhost:9999");
16 | url.searchParams.append("token", token);
17 | return redirect(url.toString());
18 | }
19 |
--------------------------------------------------------------------------------
/admin/app/clusters/CreateClusterButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { client } from "@/client/client";
4 | import { Button } from "@/components/ui/button";
5 | import { toast } from "react-hot-toast";
6 |
7 | export const CreateClusterButton = ({ token }: { token: string }) => {
8 | return (
9 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/admin/app/clusters/[clusterId]/Navigation.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 |
7 | export function Navigation({ clusterId }: { clusterId: string }) {
8 | const currentPath = usePathname();
9 |
10 | return (
11 |
12 |
18 |
24 |
30 | {/* */}
36 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/admin/app/clusters/[clusterId]/SecretKeyReveal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { clipboardSvg } from "@/components/ui/clipboard-button";
5 | import { useState } from "react";
6 | import { toast } from "react-hot-toast";
7 |
8 | const padRightWithAsterisks = (str: string) => {
9 | const asterisksForPadding = str.length - 8;
10 |
11 | return `${str.slice(0, 8)}${"*".repeat(asterisksForPadding)}`;
12 | };
13 |
14 | export const SecretKeyReveal = ({ secretKey }: { secretKey: string }) => {
15 | const [revealed, setRevealed] = useState(false);
16 |
17 | return (
18 |
19 |
20 | {revealed ? secretKey : padRightWithAsterisks(secretKey)}
21 |
22 |
23 |
33 |
36 | {/* Revoke */}
37 |
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/admin/app/clusters/[clusterId]/helpers.tsx:
--------------------------------------------------------------------------------
1 | export function LiveGreenCircle() {
2 | return (
3 |
4 | );
5 | }
6 |
7 | export function SmallLiveGreenCircle() {
8 | return (
9 |
10 | );
11 | }
12 |
13 | export function LiveAmberCircle() {
14 | return (
15 |
16 | );
17 | }
18 |
19 | export function DeadRedCircle() {
20 | return ;
21 | }
22 |
23 | export function DeadGreenCircle() {
24 | return ;
25 | }
26 |
27 | export function DeadGrayCircle() {
28 | // a gray circle that is gray when the machine is dead
29 | return ;
30 | }
31 |
32 | export function functionStatusToCircle(status: string) {
33 | // "pending", "running", "success", "failure"
34 | switch (status) {
35 | case "pending":
36 | return ;
37 | case "running":
38 | return ;
39 | case "success":
40 | return ;
41 | case "failure":
42 | return ;
43 | case "stalled":
44 | return ;
45 | default:
46 | return ;
47 | }
48 | }
49 |
50 | export function deploymentStatusToCircle(status: string) {
51 | // "uploading", "ready", "active", "inactive"
52 | switch (status) {
53 | case "uploading":
54 | case "ready":
55 | return ;
56 | case "active":
57 | return ;
58 | default:
59 | return ;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/admin/app/clusters/[clusterId]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navigation } from "./Navigation";
2 |
3 | export default function Layout({
4 | params,
5 | children,
6 | }: {
7 | params: { clusterId: string };
8 | children: React.ReactNode;
9 | }) {
10 | return (
11 |
12 |
13 |
Differential Cluster
14 |
{params.clusterId}
15 |
16 |
17 | {children}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/admin/app/clusters/[clusterId]/overview/CopyButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import toast from "react-hot-toast";
5 |
6 | export const CopyButton = ({ script }: { script: string }) => (
7 |
17 | );
18 |
--------------------------------------------------------------------------------
/admin/app/clusters/[clusterId]/services/ServiceSummary.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
2 | import Link from "next/link";
3 | import { SmallLiveGreenCircle } from "../helpers";
4 |
5 | const Service = (props: {
6 | clusterId: string;
7 | name: string;
8 | functions?: Array<{ name: string }>;
9 | activeFunctions?: Array<{ name: string }>;
10 | }) => {
11 | return (
12 |
16 |
17 |
18 | service
19 | {props.name}
20 |
21 |
22 | functions
23 |
24 | {props.functions
25 | ?.sort((a, b) => (a.name < b.name ? -1 : 1))
26 | .map((fn) => (
27 |
31 | {fn.name}()
32 | {props.activeFunctions?.find((f) => f.name === fn.name) && (
33 |
34 |
35 |
36 | )}
37 |
38 | ))}
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export const ServiceSummary = (props: {
47 | clusterId: string;
48 | services: Array<{ name: string; functions?: Array<{ name: string }> }>;
49 | activeFunctions?: Array<{ name: string; service: string }>;
50 | }) => {
51 | return (
52 |
53 | {props.services
54 | .sort((a, b) => (a.name < b.name ? -1 : 1))
55 | .map((s) => (
56 |
62 | ))}
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/admin/app/clusters/[clusterId]/services/[serviceName]/page.tsx:
--------------------------------------------------------------------------------
1 | import { client } from "@/client/client";
2 | import { auth } from "@clerk/nextjs";
3 | import { ServiceDeployments } from "./ServiceDeployments";
4 | import { ServiceLiveTables } from "./ServiceLiveTables";
5 | import { ServiceMetrics } from "./ServiceMetrics";
6 |
7 | export default async function Page({
8 | params,
9 | }: {
10 | params: { clusterId: string; serviceName: string };
11 | }) {
12 | const { getToken } = await auth();
13 |
14 | const token = await getToken();
15 |
16 | if (!token) {
17 | return null;
18 | }
19 |
20 | const clusterResult = await client.getClusterDetailsForUser({
21 | headers: {
22 | authorization: `Bearer ${token}`,
23 | },
24 | params: {
25 | clusterId: params.clusterId,
26 | },
27 | });
28 |
29 | if (clusterResult.status !== 200) {
30 | return null;
31 | }
32 |
33 | return (
34 |
35 |
36 |
Differential Service
37 |
{params.serviceName}
38 |
39 |
40 |
44 |
45 |
50 |
51 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/admin/app/clusters/fill-dates.ts:
--------------------------------------------------------------------------------
1 | function thirtyMinutesAgo() {
2 | const now = new Date();
3 | now.setMinutes(now.getMinutes() - 30);
4 | return now;
5 | }
6 |
7 | export function fillDates(data: Array): T[] {
8 | const sorted = data.sort(
9 | (a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
10 | );
11 |
12 | const earliestTimestamp = Math.min(
13 | sorted[0].timestamp.getTime(),
14 | thirtyMinutesAgo().getTime(),
15 | );
16 | const latestTimestamp = new Date().getTime();
17 |
18 | const dataApplicable = data.filter(
19 | (d) => d.timestamp?.getTime() >= earliestTimestamp,
20 | );
21 |
22 | const timestamps = dataApplicable
23 | .map((d) => d.timestamp.getTime())
24 | .filter((t) => t >= earliestTimestamp);
25 |
26 | const missingTimestamps = Array.from(
27 | { length: (latestTimestamp - earliestTimestamp) / 60000 },
28 | (_, i) => i * 60000 + earliestTimestamp,
29 | ).filter((timestamp) => !timestamps.includes(timestamp));
30 |
31 | return dataApplicable
32 | .concat(
33 | missingTimestamps.map((timestamp) => ({
34 | timestamp: new Date(timestamp),
35 | })) as T[],
36 | )
37 | .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
38 | }
39 |
--------------------------------------------------------------------------------
/admin/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/admin/app/favicon.ico
--------------------------------------------------------------------------------
/admin/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/admin/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ClerkProvider, UserButton } from "@clerk/nextjs";
2 | import type { Metadata } from "next";
3 | import { Inter } from "next/font/google";
4 | import Image from "next/image";
5 | import { Toaster } from "react-hot-toast";
6 | import "./globals.css";
7 | import logo from "./logo.png";
8 | import { Inter as FontSans } from "next/font/google";
9 | import { cn } from "@/lib/utils";
10 |
11 | const fontSans = FontSans({
12 | subsets: ["latin"],
13 | variable: "--font-sans",
14 | });
15 |
16 | const json = require("../package.json");
17 |
18 | export const metadata: Metadata = {
19 | title: "Admin Console",
20 | description: "Admin Console for Differential",
21 | };
22 |
23 | export default function RootLayout({
24 | children,
25 | }: {
26 | children: React.ReactNode;
27 | }) {
28 | return (
29 |
30 |
31 |
37 |
38 |
39 |
57 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/admin/app/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/admin/app/logo.png
--------------------------------------------------------------------------------
/admin/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | export default function Home() {
4 | // redirect to /clusters
5 | redirect("/clusters");
6 | }
7 |
--------------------------------------------------------------------------------
/admin/client/client.ts:
--------------------------------------------------------------------------------
1 | import { initClient } from "@ts-rest/core";
2 | import { contract } from "./contract";
3 |
4 | export const client = initClient(contract, {
5 | baseUrl:
6 | process.env.DIFFERENTIAL_API_URL ||
7 | process.env.NEXT_PUBLIC_DIFFERENTIAL_API_URL ||
8 | "https://api.differential.dev",
9 | baseHeaders: {},
10 | });
11 |
--------------------------------------------------------------------------------
/admin/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/admin/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | },
24 | );
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | export { Badge, badgeVariants };
37 |
--------------------------------------------------------------------------------
/admin/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/admin/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/admin/components/ui/clipboard-button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const clipboardSvg = () => (
4 |
17 | );
18 |
--------------------------------------------------------------------------------
/admin/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
4 |
5 | const Collapsible = CollapsiblePrimitive.Root;
6 |
7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
8 |
9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
10 |
11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
12 |
--------------------------------------------------------------------------------
/admin/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/admin/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ));
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ));
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
47 |
48 | export { ScrollArea, ScrollBar };
49 |
--------------------------------------------------------------------------------
/admin/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SwitchPrimitives from "@radix-ui/react-switch";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ));
27 | Switch.displayName = SwitchPrimitives.Root.displayName;
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/admin/kubernetes.dev.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: admin
5 | labels:
6 | app: admin
7 | spec:
8 | selector:
9 | matchLabels:
10 | app: admin
11 | template:
12 | metadata:
13 | labels:
14 | app: admin
15 | spec:
16 | containers:
17 | - name: admin
18 | image: admin-image
19 | ports:
20 | - containerPort: 3000
21 | env:
22 | - name: DIFFERENTIAL_API_URL
23 | value: http://host.docker.internal:4000
24 | - name: NEXT_PUBLIC_DIFFERENTIAL_API_URL
25 | value: http://localhost:4000
26 |
--------------------------------------------------------------------------------
/admin/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/admin/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | // This example protects all routes including api/trpc routes
4 | // Please edit this to allow other routes to be public as needed.
5 | // See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
6 | export default authMiddleware({});
7 |
8 | export const config = {
9 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
10 | };
11 |
--------------------------------------------------------------------------------
/admin/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | module.exports = nextConfig;
5 |
--------------------------------------------------------------------------------
/admin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "admin",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "update-contract": "mkdir -p client && cp ../control-plane/src/modules/contract.ts client/"
11 | },
12 | "dependencies": {
13 | "@clerk/clerk-sdk-node": "^4.13.4",
14 | "@clerk/nextjs": "^4.29.3",
15 | "@hookform/resolvers": "^3.3.4",
16 | "@radix-ui/react-collapsible": "^1.0.3",
17 | "@radix-ui/react-dialog": "^1.0.5",
18 | "@radix-ui/react-icons": "^1.3.0",
19 | "@radix-ui/react-label": "^2.0.2",
20 | "@radix-ui/react-navigation-menu": "^1.1.4",
21 | "@radix-ui/react-scroll-area": "^1.0.5",
22 | "@radix-ui/react-slot": "^1.0.2",
23 | "@radix-ui/react-switch": "^1.0.3",
24 | "@tanstack/react-table": "^8.11.2",
25 | "@ts-rest/core": "^3.30.5",
26 | "@ts-rest/react-query": "^3.30.5",
27 | "@types/react-syntax-highlighter": "^15.5.11",
28 | "class-variance-authority": "^0.7.0",
29 | "clsx": "^2.0.0",
30 | "date-fns": "^3.0.6",
31 | "jwks-rsa": "^3.1.0",
32 | "lucide-react": "^0.309.0",
33 | "msgpackr": "^1.10.1",
34 | "next": "14.2.5",
35 | "react": "^18",
36 | "react-dom": "^18",
37 | "react-hook-form": "^7.50.1",
38 | "react-hot-toast": "^2.4.1",
39 | "react-json-pretty": "^2.2.0",
40 | "react-loader-spinner": "^6.1.6",
41 | "react-syntax-highlighter": "^15.5.0",
42 | "recharts": "^2.10.4",
43 | "tailwind-merge": "^2.2.0",
44 | "tailwindcss-animate": "^1.0.7",
45 | "vaul": "^0.8.0",
46 | "zod": "^3.22.4"
47 | },
48 | "devDependencies": {
49 | "@types/node": "^20",
50 | "@types/react": "^18",
51 | "@types/react-dom": "^18",
52 | "autoprefixer": "^10.0.1",
53 | "eslint": "^8",
54 | "eslint-config-next": "14.0.4",
55 | "postcss": "^8",
56 | "tailwindcss": "^3.3.0",
57 | "typescript": "^5"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/admin/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/admin/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | }
--------------------------------------------------------------------------------
/admin/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | const { fontFamily } = require("tailwindcss/defaultTheme");
3 |
4 | const config: Config = {
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | fontFamily: {
13 | sans: ["var(--font-sans)", ...fontFamily.sans],
14 | },
15 | },
16 | },
17 | };
18 | export default config;
19 |
--------------------------------------------------------------------------------
/admin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./*"
28 | ]
29 | }
30 | },
31 | "include": [
32 | "next-env.d.ts",
33 | "**/*.ts",
34 | "**/*.tsx",
35 | ".next/types/**/*.ts",
36 | "app/clusters/[clusterId]/page.tsx_",
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
--------------------------------------------------------------------------------
/assets/differential-cloud-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/assets/differential-cloud-2.png
--------------------------------------------------------------------------------
/assets/differential-cloud.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/assets/differential-cloud.gif
--------------------------------------------------------------------------------
/assets/image-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/assets/image-3.png
--------------------------------------------------------------------------------
/cli/differential.local.json:
--------------------------------------------------------------------------------
1 | {
2 | "apiUrl": "http://localhost:4000",
3 | "consoleUrl": "http://localhost:3000"
4 | }
--------------------------------------------------------------------------------
/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@differentialhq/cli",
3 | "version": "3.19.0",
4 | "description": "CLI for differential.dev",
5 | "bin": {
6 | "differential": "bin/index.js"
7 | },
8 | "scripts": {
9 | "test": "exit 0",
10 | "start": "node ./bin/index.js",
11 | "build": "tsc",
12 | "clean": "rm -rf ./bin",
13 | "dev": "npm run build && npm run start"
14 | },
15 | "author": "",
16 | "license": "ISC",
17 | "dependencies": {
18 | "@differentialhq/core": "^3.18.0",
19 | "@inquirer/prompts": "^4.0.0",
20 | "@ts-rest/core": "^3.28.0",
21 | "acorn": "^8.11.3",
22 | "acorn-walk": "^8.3.2",
23 | "debug": "^4.3.4",
24 | "detective": "^5.2.1",
25 | "tslib": "^2.6.2",
26 | "typescript": "^5.2.2",
27 | "ulid": "^2.3.0",
28 | "yargs": "^17.7.2",
29 | "zip-a-folder": "^3.1.6",
30 | "zod": "^3.22.2"
31 | },
32 | "devDependencies": {
33 | "@types/debug": "^4.1.8",
34 | "@types/detective": "^5.1.5",
35 | "@types/jest": "^29.5.4",
36 | "@types/yargs": "^17.0.32",
37 | "jest": "^29.6.4",
38 | "ts-morph": "^22.0.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/cli/src/commands/auth.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule, showHelp } from "yargs";
2 | import { startTokenFlow } from "../lib/auth";
3 |
4 | export const Auth: CommandModule = {
5 | command: "auth",
6 | describe: "Authenticate with the Differential API.",
7 | builder: (yargs) =>
8 | yargs.command({
9 | command: "login",
10 | describe: "Authenticate the Differential CLI.",
11 | handler: () => {
12 | startTokenFlow();
13 | },
14 | }),
15 | handler: async () => {
16 | showHelp();
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/cli/src/commands/client-lib.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule, showHelp } from "yargs";
2 | import { ClientLibraryPublish } from "./client-lib-publish";
3 |
4 | export const ClientLibrary: CommandModule = {
5 | command: "client",
6 | describe: "Manage Differential client libraries",
7 | builder: (yargs) => yargs.command(ClientLibraryPublish),
8 | handler: async () => {
9 | showHelp();
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/cli/src/commands/clusters-create.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { client } from "../lib/client";
3 |
4 | interface ClusterCreateArgs {
5 | description?: string;
6 | }
7 | export const ClusterCreate: CommandModule<{}, ClusterCreateArgs> = {
8 | command: "create",
9 | describe: "Create a new Differential cluster",
10 | builder: (yargs) =>
11 | yargs.option("description", {
12 | describe: "Cluster Description",
13 | demandOption: false,
14 | type: "string",
15 | }),
16 | handler: async ({ description }) => {
17 | const d = await client.createClusterForUser({
18 | body: {
19 | description: description ?? "CLI Created Cluster",
20 | },
21 | });
22 |
23 | if (d.status !== 204) {
24 | console.error(`Failed to create cluster: ${d.status}`);
25 | return;
26 | } else {
27 | console.log("Cluster created successfully");
28 |
29 | const clusters = await client.getClustersForUser();
30 |
31 | if (clusters.status === 200) {
32 | const cluster = clusters.body.sort((a, b) =>
33 | a.createdAt > b.createdAt ? -1 : 1,
34 | )[0];
35 |
36 | console.log(cluster);
37 | }
38 | }
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/cli/src/commands/clusters-info.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { client } from "../lib/client";
3 | import { selectCluster } from "../utils";
4 |
5 | interface ClusterInfoArgs {
6 | cluster?: string;
7 | }
8 | export const ClusterInfo: CommandModule<{}, ClusterInfoArgs> = {
9 | command: "info",
10 | describe: "Display information about a Differential cluster",
11 | builder: (yargs) =>
12 | yargs.option("cluster", {
13 | describe: "Cluster ID",
14 | demandOption: false,
15 | type: "string",
16 | }),
17 | handler: async ({ cluster }) => {
18 | if (!cluster) {
19 | cluster = await selectCluster();
20 | if (!cluster) {
21 | console.log("No cluster selected");
22 | return;
23 | }
24 | }
25 |
26 | getClusterDetails(cluster);
27 | },
28 | };
29 |
30 | const getClusterDetails = async (clusterId: string) => {
31 | const d = await client.getClusterDetailsForUser({
32 | params: {
33 | clusterId,
34 | },
35 | });
36 | if (d.status !== 200) {
37 | console.error(`Failed to get cluster details: ${d.status}`);
38 | return;
39 | }
40 | console.log(d.body);
41 | };
42 |
--------------------------------------------------------------------------------
/cli/src/commands/clusters-list.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { client } from "../lib/client";
3 |
4 | interface ClusterCreateArgs {
5 | description?: string;
6 | }
7 | export const ClusterList: CommandModule<{}, ClusterCreateArgs> = {
8 | command: "list",
9 | aliases: ["ls"],
10 | describe: "List Differential clusters",
11 | handler: async () => {
12 | const d = await client.getClustersForUser();
13 | if (d.status !== 200) {
14 | console.error(`Failed to list clusters: ${d.status}`);
15 | return;
16 | }
17 |
18 | if (!d.body) {
19 | console.log("No clusters found");
20 | return;
21 | }
22 |
23 | console.log(
24 | d.body.map((c) => ({
25 | ...c,
26 | apiSecret:
27 | c.apiSecret.substring(0, 8) + "*".repeat(c.apiSecret.length - 8),
28 | info: `differential clusters info --cluster ${c.id}`,
29 | })),
30 | );
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/cli/src/commands/clusters-open.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { openBrowser, selectCluster } from "../utils";
3 | import { readCurrentContext } from "../lib/context";
4 |
5 | interface ClusterOpenArgs {
6 | cluster?: string;
7 | }
8 | export const ClusterOpen: CommandModule<{}, ClusterOpenArgs> = {
9 | command: "open",
10 | describe: "Open a Differential cluster in the console",
11 | builder: (yargs) =>
12 | yargs.option("cluster", {
13 | describe: "Cluster ID",
14 | demandOption: false,
15 | type: "string",
16 | }),
17 | handler: async ({ cluster }) => {
18 | if (!cluster) {
19 | cluster = await selectCluster();
20 | if (!cluster) {
21 | console.log("No cluster selected");
22 | return;
23 | }
24 | }
25 |
26 | openBrowser(
27 | `${readCurrentContext().npmRegistryUrl}/clusters/${cluster}/overview`,
28 | );
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/cli/src/commands/clusters.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule, showHelp } from "yargs";
2 | import { ClusterCreate } from "./clusters-create";
3 | import { ClusterList } from "./clusters-list";
4 | import { ClusterOpen } from "./clusters-open";
5 | import { ClusterInfo } from "./clusters-info";
6 |
7 | export const Clusters: CommandModule = {
8 | command: "clusters",
9 | aliases: ["cluster", "c"],
10 | describe: "Manage Differential clusters",
11 | builder: (yargs) =>
12 | yargs
13 | .command(ClusterCreate)
14 | .command(ClusterList)
15 | .command(ClusterOpen)
16 | .command(ClusterInfo),
17 | handler: async () => {
18 | showHelp();
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/cli/src/commands/context-info.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { readCurrentContext } from "../lib/context";
3 |
4 | export const ContextInfo: CommandModule = {
5 | command: "info",
6 | describe: "View CLI context details",
7 | handler: async () => {
8 | console.log(readCurrentContext());
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/cli/src/commands/context-set.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { CliContext, getCurrentContext, saveContext } from "../lib/context";
3 |
4 | type ContextSetArgs = Partial & {
5 | context?: string;
6 | };
7 |
8 | export const ContextSet: CommandModule<{}, ContextSetArgs> = {
9 | command: "set",
10 | describe: "Update CLI context values",
11 | builder: (yargs) =>
12 | yargs
13 | .option("apiUrl", {
14 | describe: "Control Plane API host",
15 | demandOption: false,
16 | type: "string",
17 | })
18 | .option("consoleUrl", {
19 | describe: "Management Console host",
20 | demandOption: false,
21 | type: "string",
22 | })
23 | .option("cluster", {
24 | describe: "Cluster ID",
25 | demandOption: false,
26 | type: "string",
27 | })
28 | .option("service", {
29 | describe: "Service name",
30 | demandOption: false,
31 | type: "string",
32 | })
33 | .option("deployment", {
34 | describe: "Deployment id",
35 | demandOption: false,
36 | type: "string",
37 | }),
38 | handler: async ({
39 | context,
40 | apiUrl,
41 | consoleUrl,
42 | cluster,
43 | service,
44 | deployment,
45 | }: ContextSetArgs) => {
46 | if (!context) {
47 | context = getCurrentContext();
48 | }
49 | saveContext(
50 | {
51 | ...mapKey("apiUrl", apiUrl),
52 | ...mapKey("consoleUrl", consoleUrl),
53 | ...mapKey("cluster", cluster),
54 | ...mapKey("service", service),
55 | ...mapKey("deployment", deployment),
56 | },
57 | context,
58 | );
59 | },
60 | };
61 |
62 | const mapKey = (key: string, value?: string) => {
63 | if (value === undefined) return {};
64 |
65 | if (value === "") {
66 | console.log(`Clearing context key: ${key}`);
67 | return { [key]: undefined };
68 | }
69 |
70 | return { [key]: value };
71 | };
72 |
--------------------------------------------------------------------------------
/cli/src/commands/context.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { ContextSet } from "./context-set";
3 | import { ContextInfo } from "./context-info";
4 |
5 | export const Context: CommandModule = {
6 | command: "context",
7 | aliases: ["context"],
8 | describe: "Manage Differential CLI context",
9 | builder: (yargs) => yargs.command(ContextInfo).command(ContextSet),
10 | handler: ContextInfo.handler,
11 | };
12 |
--------------------------------------------------------------------------------
/cli/src/commands/deploy-info.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { client } from "../lib/client";
3 | import { cloudEnabledCheck } from "../lib/auth";
4 |
5 | interface DeployInfoArgs {
6 | cluster: string;
7 | service: string;
8 | deployment: string;
9 | }
10 | export const DeployInfo: CommandModule<{}, DeployInfoArgs> = {
11 | command: "info",
12 | describe: "Display information about a Differential cloud deployment",
13 | builder: (yargs) =>
14 | yargs
15 | .option("cluster", {
16 | describe: "Cluster ID",
17 | demandOption: true,
18 | type: "string",
19 | })
20 | .option("service", {
21 | describe: "Service name",
22 | demandOption: true,
23 | type: "string",
24 | })
25 | .option("deployment", {
26 | describe: "Deployment ID",
27 | demandOption: true,
28 | type: "string",
29 | }),
30 | handler: async ({ cluster, service, deployment }) => {
31 | if (!(await cloudEnabledCheck(cluster))) {
32 | return;
33 | }
34 |
35 | const d = await client.getDeployment({
36 | params: {
37 | clusterId: cluster,
38 | serviceName: service,
39 | deploymentId: deployment,
40 | },
41 | });
42 | if (d.status !== 200) {
43 | console.error(`Failed to fetch deployment info: ${d.status}`);
44 | return;
45 | }
46 |
47 | console.log(d.body);
48 | },
49 | };
50 |
--------------------------------------------------------------------------------
/cli/src/commands/deploy-list.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { client } from "../lib/client";
3 | import { selectCluster, selectService } from "../utils";
4 | import { cloudEnabledCheck } from "../lib/auth";
5 |
6 | interface DeployListArgs {
7 | cluster?: string;
8 | service?: string;
9 | }
10 | export const DeployList: CommandModule<{}, DeployListArgs> = {
11 | command: "list",
12 | aliases: ["ls"],
13 | describe: "List Differential cloud deployments",
14 | builder: (yargs) =>
15 | yargs
16 | .option("cluster", {
17 | describe: "Cluster ID",
18 | demandOption: false,
19 | type: "string",
20 | })
21 | .option("service", {
22 | describe: "Service name",
23 | demandOption: false,
24 | type: "string",
25 | }),
26 | handler: async ({ cluster, service }) => {
27 | if (!cluster) {
28 | cluster = await selectCluster();
29 | if (!cluster) {
30 | console.log("No cluster selected");
31 | return;
32 | }
33 | }
34 |
35 | if (!(await cloudEnabledCheck(cluster))) {
36 | return;
37 | }
38 |
39 | if (!service) {
40 | service = await selectService(cluster);
41 | if (!service) {
42 | console.log("No service selected");
43 | return;
44 | }
45 | }
46 |
47 | const d = await client.getDeployments({
48 | params: {
49 | clusterId: cluster,
50 | serviceName: service,
51 | },
52 | });
53 | if (d.status !== 200) {
54 | console.error(`Failed to fetch deployment info: ${d.status}`);
55 | return;
56 | }
57 |
58 | console.log(d.body);
59 | },
60 | };
61 |
--------------------------------------------------------------------------------
/cli/src/commands/deploy.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule, showHelp } from "yargs";
2 | import { DeployCreate } from "./deploy-create";
3 | import { DeployInfo } from "./deploy-info";
4 | import { DeployList } from "./deploy-list";
5 | import { DeployLogs } from "./deploy-logs";
6 |
7 | export const Deploy: CommandModule = {
8 | command: "deployments",
9 | aliases: ["deployment", "deploy", "d"],
10 | describe: "Manage Differential cloud deployments",
11 | builder: (yargs) =>
12 | yargs
13 | .command(DeployCreate)
14 | .command(DeployList)
15 | .command(DeployInfo)
16 | .command(DeployLogs),
17 | handler: async () => {
18 | showHelp();
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/cli/src/commands/services-register.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import { Project } from "ts-morph";
3 | import { CommandModule } from "yargs";
4 | import { client } from "../lib/client";
5 | import { selectCluster } from "../utils";
6 |
7 | interface ServicesRegisterArgs {
8 | cluster?: string;
9 | service: string;
10 | }
11 |
12 | export const ServicesRegister: CommandModule<{}, ServicesRegisterArgs> = {
13 | command: "register",
14 | describe: "Explicitly register a service with Cluster",
15 | builder: (yargs) =>
16 | yargs
17 | .option("cluster", {
18 | describe: "Cluster ID",
19 | demandOption: false,
20 | type: "string",
21 | })
22 | .option("service", {
23 | describe: "Service name",
24 | demandOption: true,
25 | type: "string",
26 | }),
27 | handler: async ({ cluster, service }) => {
28 | if (!cluster) {
29 | cluster = await selectCluster();
30 | if (!cluster) {
31 | console.log("No cluster selected");
32 | process.exit(1);
33 | }
34 | }
35 |
36 | // check if the name.service.ts exists
37 | if (!fs.existsSync(`${service}.service.ts`)) {
38 | throw new Error(`Service file ${service}.service.ts not found`);
39 | }
40 |
41 | const project = new Project({
42 | compilerOptions: {
43 | declaration: true,
44 | emitDecoratorMetadata: true,
45 | emitDeclarationOnly: true,
46 | outFile: "service.d.ts",
47 | },
48 | });
49 |
50 | project.addSourceFileAtPath(`${service}.service.ts`);
51 |
52 | const result = project.emitToMemory();
53 |
54 | const typesText = result
55 | .getFiles()
56 | .find((x) => x.filePath.includes("service.d.ts"))?.text;
57 |
58 | if (!typesText) {
59 | throw new Error("Failed to generate types for service");
60 | }
61 |
62 | const storeResult = await client.storeSchema({
63 | body: {
64 | schema: typesText,
65 | },
66 | params: {
67 | clusterId: cluster,
68 | serviceName: service,
69 | },
70 | });
71 |
72 | if (storeResult.status !== 204) {
73 | console.log("Failed to register service", {
74 | result: storeResult,
75 | });
76 | }
77 |
78 | console.log(`Service ${service} registered with cluster ${cluster}`);
79 | },
80 | };
81 |
--------------------------------------------------------------------------------
/cli/src/commands/services.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule, showHelp } from "yargs";
2 | import { ServicesRegister } from "./services-register";
3 |
4 | export const Services: CommandModule = {
5 | command: "services",
6 | aliases: ["s"],
7 | describe: "Manage Differential services",
8 | builder: (yargs) => yargs.command(ServicesRegister),
9 | handler: async () => {
10 | showHelp();
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/cli/src/commands/task.ts:
--------------------------------------------------------------------------------
1 | import { CommandModule } from "yargs";
2 | import { selectCluster } from "../utils";
3 | import { client } from "../lib/client";
4 | import { input } from "@inquirer/prompts";
5 |
6 | interface TaskArgs {
7 | cluster?: string;
8 | task?: string;
9 | }
10 |
11 | export const Task: CommandModule<{}, TaskArgs> = {
12 | command: "task",
13 | describe: "Execute a task in the cluster using a human readable prompt",
14 | builder: (yargs) =>
15 | yargs
16 | .option("cluster", {
17 | describe: "Cluster ID",
18 | demandOption: false,
19 | type: "string",
20 | })
21 | .option("task", {
22 | describe: "Task for the cluster to perform",
23 | demandOption: false,
24 | type: "string",
25 | }),
26 | handler: async ({ cluster, task }) => {
27 | if (!cluster) {
28 | cluster = await selectCluster();
29 | if (!cluster) {
30 | console.log("No cluster selected");
31 | return;
32 | }
33 | }
34 |
35 | if (!task) {
36 | task = await input({
37 | message: "Human readable prompt for the cluster to perform",
38 | validate: (value) => {
39 | if (!value) {
40 | return "Prompt is required";
41 | }
42 | return true;
43 | },
44 | });
45 | }
46 |
47 | try {
48 | const result = await executeTask(cluster, task);
49 | console.log(result);
50 | } catch (e) {
51 | console.error(e);
52 | }
53 | },
54 | };
55 |
56 | const executeTask = async (clusterId: string, task: string) => {
57 | const result = await client.executeTask({
58 | params: {
59 | clusterId,
60 | },
61 | body: {
62 | task,
63 | },
64 | });
65 |
66 | if (result.status !== 200) {
67 | throw new Error(`Failed to prompt cluster: ${result.status}`);
68 | }
69 | return result.body.result;
70 | };
71 |
--------------------------------------------------------------------------------
/cli/src/constants.ts:
--------------------------------------------------------------------------------
1 | import { CliContext } from "./lib/context";
2 |
3 | export const DEFAULT_CONSOLE_URL = "https://console.differential.dev";
4 | export const DEFAULT_API_URL = "https://api.differential.dev";
5 | export const NPM_REGISTRY_PATH = `/packages/npm/`;
6 | export const CLIENT_PACKAGE_SCOPE = "@differential.dev";
7 |
8 | export const DEFAULT_CLI_CONTEXT: CliContext = {
9 | apiUrl: DEFAULT_API_URL,
10 | consoleUrl: DEFAULT_CONSOLE_URL,
11 | npmRegistryUrl: DEFAULT_API_URL + NPM_REGISTRY_PATH,
12 | };
13 |
--------------------------------------------------------------------------------
/cli/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import yargs from "yargs";
4 | import { hideBin } from "yargs/helpers";
5 |
6 | import { Deploy } from "./commands/deploy";
7 | import { Auth } from "./commands/auth";
8 | import { getToken } from "./lib/auth";
9 | import { Clusters } from "./commands/clusters";
10 | import { ClientLibrary } from "./commands/client-lib";
11 | import { Repl } from "./commands/repl";
12 | import { Context } from "./commands/context";
13 | import { setCurrentContext } from "./lib/context";
14 | import { Services } from "./commands/services";
15 | import { Task } from "./commands/task";
16 |
17 | const cli = yargs(hideBin(process.argv))
18 | .scriptName("differential")
19 | .strict()
20 | .hide("version")
21 | .option("context", {
22 | describe: "Configuration context",
23 | demandOption: false,
24 | default: "default",
25 | type: "string",
26 | })
27 | .middleware(setCurrentContext)
28 | .demandCommand()
29 | .command(Auth);
30 |
31 | const authenticated = getToken();
32 | if (authenticated) {
33 | cli
34 | .command(Deploy)
35 | .command(Clusters)
36 | .command(ClientLibrary)
37 | .command(Repl)
38 | .command(Context)
39 | .command(Task)
40 | .command(Services);
41 | } else {
42 | console.log(
43 | "Diffential CLI is not authenticated. Please run `differential auth login` to authenticate.",
44 | );
45 | }
46 |
47 | cli.argv;
48 |
--------------------------------------------------------------------------------
/cli/src/lib/client.ts:
--------------------------------------------------------------------------------
1 | import { initClient, tsRestFetchApi } from "@ts-rest/core";
2 | import { contract } from "../client/contract";
3 |
4 | import { getToken } from "../lib/auth";
5 | import { readCurrentContext } from "./context";
6 |
7 | export const client = initClient(contract, {
8 | baseUrl: readCurrentContext().apiUrl,
9 | baseHeaders: {
10 | authorization: `Bearer ${getToken()}`,
11 | },
12 | api: tsRestFetchApi,
13 | });
14 |
15 | export const waitForDeploymentStatus = async (
16 | deploymentId: string,
17 | serviceName: string,
18 | clusterId: string,
19 | targets: string[],
20 | maxAttempts: number = 10,
21 | ): Promise => {
22 | const checkDeployment = () =>
23 | client.getDeployment({
24 | params: {
25 | deploymentId,
26 | clusterId,
27 | serviceName,
28 | },
29 | });
30 |
31 | let attempts = 0;
32 | while (attempts < maxAttempts) {
33 | attempts++;
34 | const result = await checkDeployment();
35 |
36 | if (result.status == 200 && targets.includes(result.body.status)) {
37 | return result.body.status;
38 | }
39 | await new Promise((resolve) => setTimeout(resolve, 1000));
40 | }
41 |
42 | throw new Error(
43 | "Failed to check deployment status. Please check provided options and cluster configuration.",
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/cli/src/lib/context.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import fs from "fs";
3 | import { DEFAULT_CLI_CONTEXT } from "../constants";
4 |
5 | export type CliContext = {
6 | apiUrl: string;
7 | consoleUrl: string;
8 | npmRegistryUrl: string;
9 | cluster?: string;
10 | service?: string;
11 | deployment?: string;
12 | };
13 |
14 | let CURRENT_CONTEXT = "default";
15 |
16 | export const setCurrentContext = ({ context }: { context: string }) => {
17 | console.log(`Running with configuration context: ${context}`);
18 | CURRENT_CONTEXT = context;
19 | };
20 |
21 | export const getCurrentContext = () => {
22 | return CURRENT_CONTEXT;
23 | };
24 |
25 | export const saveContext = (
26 | inputContext: Partial,
27 | name?: string,
28 | ) => {
29 | const updatedContext = {
30 | ...readContext(name),
31 | ...inputContext,
32 | };
33 |
34 | fs.writeFileSync(getContextPath(name), JSON.stringify(updatedContext));
35 | };
36 |
37 | // Loads the context specified or the "default" context if not.
38 | export const readContext = (name?: string): CliContext => {
39 | let context = DEFAULT_CLI_CONTEXT;
40 |
41 | // Load the default context for merging
42 | if (name) {
43 | context = readContext();
44 | }
45 |
46 | const contextFile = getContextPath(name);
47 | if (fs.existsSync(contextFile)) {
48 | const file = fs.readFileSync(contextFile, { encoding: "utf-8" });
49 | // Merge default context
50 | context = {
51 | ...context,
52 | ...JSON.parse(file),
53 | };
54 | }
55 | return context;
56 | };
57 |
58 | export const readCurrentContext = () => {
59 | return readContext(getCurrentContext());
60 | };
61 |
62 | const getContextPath = (name: string = "default") => {
63 | return path.join(
64 | process.cwd(),
65 | name === "default" ? "differential.json" : `differential.${name}.json`,
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/cli/src/lib/release.ts:
--------------------------------------------------------------------------------
1 | import { client } from "./client";
2 |
3 | export const release = async ({
4 | deploymentId,
5 | clusterId,
6 | serviceName,
7 | }: {
8 | deploymentId: string;
9 | clusterId: string;
10 | serviceName: string;
11 | }) => {
12 | const result = await client.releaseDeployment({
13 | params: {
14 | deploymentId,
15 | clusterId,
16 | serviceName,
17 | },
18 | });
19 | if (result.status !== 200) {
20 | throw new Error(
21 | "Failed to deploy service. Please check provided options and cluster configuration.",
22 | );
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/cli/src/lib/upload.ts:
--------------------------------------------------------------------------------
1 | import debug from "debug";
2 | import { readFileSync } from "fs";
3 | import { client } from "./client";
4 |
5 | const log = debug("differential:cli:upload");
6 |
7 | export const uploadAsset = async ({
8 | path,
9 | target,
10 | contentType,
11 | type,
12 | cluster,
13 | }: {
14 | path: string;
15 | target: string;
16 | contentType: string;
17 | type: "client_library" | "service_bundle";
18 | cluster: string;
19 | }): Promise => {
20 | log(`Uploading asset`);
21 |
22 | const upload = await client.createAsset({
23 | body: {
24 | type,
25 | target,
26 | },
27 | params: {
28 | clusterId: cluster,
29 | },
30 | });
31 |
32 | log("Response from createAsset", upload);
33 |
34 | if (upload.status !== 201) {
35 | throw new Error(
36 | "Failed to upload asset. Please check provided options and cluster configuration.",
37 | );
38 | }
39 |
40 | const { presignedUrl } = upload.body;
41 |
42 | log("Uploading asset to s3", { presignedUrl });
43 |
44 | const response = await fetch(presignedUrl, {
45 | method: "PUT",
46 | body: readFileSync(path),
47 | headers: {
48 | "Content-Type": contentType,
49 | },
50 | });
51 |
52 | log("Response from S3 put", response);
53 |
54 | if (response.status !== 200) {
55 | throw new Error(
56 | "Failed to upload asset. Please check provided options and cluster configuration.",
57 | );
58 | }
59 | };
60 |
--------------------------------------------------------------------------------
/cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "commonjs",
5 | "outDir": "bin",
6 | "rootDir": "src",
7 | "declaration": true,
8 | "sourceMap": true,
9 | "strict": true,
10 | "esModuleInterop": true,
11 | "allowJs": true
12 | },
13 | "include": [
14 | "src/**/*"
15 | ],
16 | "exclude": [
17 | "node_modules"
18 | ]
19 | }
--------------------------------------------------------------------------------
/control-plane/.dockerignore:
--------------------------------------------------------------------------------
1 | .env
--------------------------------------------------------------------------------
/control-plane/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [0.0.16](https://github.com/differentialHQ/differential/compare/v0.0.0...v0.0.16) (2023-12-24)
2 |
3 |
4 | ### Features
5 |
6 | * Adding function support ([372b897](https://github.com/differentialHQ/differential/commit/372b897dbad2ea3b871d6e5c0bdb28d121995cd8))
7 | * Adding long polling support for control-plane ([98a6b69](https://github.com/differentialHQ/differential/commit/98a6b69b340cfa67c3fa1759d0de9cfdf6c8f7ec))
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/control-plane/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax = docker/dockerfile:1
2 |
3 | # Adjust NODE_VERSION as desired
4 | ARG NODE_VERSION=18.7.0
5 | FROM node:${NODE_VERSION}-slim as base
6 |
7 | LABEL fly_launch_runtime="NodeJS"
8 |
9 | # NodeJS app lives here
10 | WORKDIR /app
11 |
12 | # Throw-away build stage to reduce size of final image
13 | FROM base as build
14 |
15 | # Install packages needed to build node modules
16 | RUN apt-get update -qq && \
17 | apt-get install -y python-is-python3 pkg-config build-essential
18 |
19 | # Install node modules
20 | COPY --link package.json package-lock.json ./
21 | RUN npm install
22 |
23 | # Copy application code
24 | COPY --link . .
25 |
26 | # Remove src/test directory
27 | RUN rm -rf src/test
28 |
29 | RUN npm run build
30 |
31 | # Remove development dependencies
32 | RUN npm install --production=true
33 |
34 | # Final stage for app image
35 | FROM base
36 |
37 | # Set production environment
38 | ENV NODE_ENV=production
39 |
40 | # Copy built application
41 | COPY --from=build /app /app
42 |
43 | # Start the server by default, this can be overwritten at runtime
44 | CMD [ "npm", "run", "start" ]
45 |
--------------------------------------------------------------------------------
/control-plane/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM node:20
2 |
3 | WORKDIR /app
4 |
5 | ADD . .
6 |
7 | RUN npm install
8 |
9 | ENTRYPOINT [ "npm", "run", "dev" ]
--------------------------------------------------------------------------------
/control-plane/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ## Overview
6 |
7 | This repo contains the source code for the Differential control plane. Control plane acts as an application code aware service mesh, and a distributed orchestrator. It implements a few things on top of Postgres:
8 |
9 | - Queue with exactly-once processing semantics
10 | - Service & function registry
11 | - Utilities for running functions in a distributed environment
12 | - APIs for interacting with the control plane
13 | - APIs for ingesting metrics and logs
14 |
15 | See the [Documentation](https://docs.differential.dev) for more information.
16 |
17 | ## Contributing
18 |
19 | We welcome contributions to Differential! Please see the [Contributing Guide](../CONTRIBUTING.md) for more information.
20 |
--------------------------------------------------------------------------------
/control-plane/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0000_odd_tattoo.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "environments" (
2 | "id" varchar(70) PRIMARY KEY NOT NULL,
3 | "user_id" varchar NOT NULL,
4 | "name" varchar(255) NOT NULL
5 | );
6 | --> statement-breakpoint
7 | CREATE TABLE IF NOT EXISTS "jobs" (
8 | "id" varchar(70) PRIMARY KEY NOT NULL,
9 | "environment_id" varchar NOT NULL,
10 | "target_fn" varchar(255) NOT NULL,
11 | "target_args" text NOT NULL,
12 | "idempotency_key" varchar(255) NOT NULL,
13 | "status" text NOT NULL,
14 | "result" text,
15 | "created_at" timestamp DEFAULT now() NOT NULL,
16 | "updated_at" timestamp DEFAULT now() NOT NULL
17 | );
18 | --> statement-breakpoint
19 | CREATE TABLE IF NOT EXISTS "machine_callable_targets" (
20 | "id" varchar(70) PRIMARY KEY NOT NULL,
21 | "machine_id" varchar NOT NULL,
22 | "target_fn" varchar(255) NOT NULL
23 | );
24 | --> statement-breakpoint
25 | CREATE TABLE IF NOT EXISTS "machines" (
26 | "id" varchar(70) PRIMARY KEY NOT NULL,
27 | "environment_id" varchar NOT NULL,
28 | "description" varchar(255),
29 | "class" varchar(255),
30 | "last_ping_at" timestamp
31 | );
32 | --> statement-breakpoint
33 | CREATE TABLE IF NOT EXISTS "users" (
34 | "id" varchar(70) PRIMARY KEY NOT NULL,
35 | "secret_key" varchar(255) NOT NULL
36 | );
37 | --> statement-breakpoint
38 | DO $$ BEGIN
39 | ALTER TABLE "environments" ADD CONSTRAINT "environments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
40 | EXCEPTION
41 | WHEN duplicate_object THEN null;
42 | END $$;
43 | --> statement-breakpoint
44 | DO $$ BEGIN
45 | ALTER TABLE "jobs" ADD CONSTRAINT "jobs_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "environments"("id") ON DELETE no action ON UPDATE no action;
46 | EXCEPTION
47 | WHEN duplicate_object THEN null;
48 | END $$;
49 | --> statement-breakpoint
50 | DO $$ BEGIN
51 | ALTER TABLE "machine_callable_targets" ADD CONSTRAINT "machine_callable_targets_machine_id_machines_id_fk" FOREIGN KEY ("machine_id") REFERENCES "machines"("id") ON DELETE no action ON UPDATE no action;
52 | EXCEPTION
53 | WHEN duplicate_object THEN null;
54 | END $$;
55 | --> statement-breakpoint
56 | DO $$ BEGIN
57 | ALTER TABLE "machines" ADD CONSTRAINT "machines_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "environments"("id") ON DELETE no action ON UPDATE no action;
58 | EXCEPTION
59 | WHEN duplicate_object THEN null;
60 | END $$;
61 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0001_tense_dreaming_celestial.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" DROP CONSTRAINT "jobs_environment_id_environments_id_fk";
2 | --> statement-breakpoint
3 | ALTER TABLE "machines" DROP CONSTRAINT "machines_environment_id_environments_id_fk";
4 | --> statement-breakpoint
5 | DROP TABLE "environments";--> statement-breakpoint
6 | DROP TABLE "machine_callable_targets";--> statement-breakpoint
7 | DROP TABLE "users";
--------------------------------------------------------------------------------
/control-plane/drizzle/0002_steady_korath.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ALTER COLUMN "id" SET DATA TYPE varchar(1024);--> statement-breakpoint
2 | ALTER TABLE "jobs" ALTER COLUMN "target_fn" SET DATA TYPE varchar(1024);--> statement-breakpoint
3 | ALTER TABLE "jobs" ALTER COLUMN "idempotency_key" SET DATA TYPE varchar(1024);--> statement-breakpoint
4 | ALTER TABLE "machines" ALTER COLUMN "id" SET DATA TYPE varchar(1024);--> statement-breakpoint
5 | ALTER TABLE "machines" ALTER COLUMN "description" SET DATA TYPE varchar(1024);--> statement-breakpoint
6 | ALTER TABLE "machines" ALTER COLUMN "class" SET DATA TYPE varchar(1024);
--------------------------------------------------------------------------------
/control-plane/drizzle/0003_living_captain_america.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD COLUMN "result_type" text;--> statement-breakpoint
2 | ALTER TABLE "jobs" ADD COLUMN "remaining" integer DEFAULT 1;
--------------------------------------------------------------------------------
/control-plane/drizzle/0004_confused_goliath.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD COLUMN "owner_id" varchar(1024);
--------------------------------------------------------------------------------
/control-plane/drizzle/0005_overconfident_hex.sql:
--------------------------------------------------------------------------------
1 | UPDATE "jobs" SET "owner_id" = 'fake_hash' WHERE "owner_id" IS NULL;--> statement-breakpoint
2 |
3 | ALTER TABLE "jobs" RENAME COLUMN "owner_id" TO "owner_hash";--> statement-breakpoint
4 | ALTER TABLE "jobs" ALTER COLUMN "owner_hash" SET DATA TYPE text;--> statement-breakpoint
5 | ALTER TABLE "jobs" ALTER COLUMN "owner_hash" SET NOT NULL;
--------------------------------------------------------------------------------
/control-plane/drizzle/0006_ambiguous_wild_pack.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD COLUMN "machine_type" text;
--------------------------------------------------------------------------------
/control-plane/drizzle/0007_icy_the_hand.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "credentials" (
2 | "id" varchar(1024) PRIMARY KEY NOT NULL,
3 | "api_key" varchar(1024) NOT NULL,
4 | "api_secret" varchar(1024) NOT NULL,
5 | "environment_id" varchar NOT NULL,
6 | "user_id" varchar NOT NULL,
7 | "created_at" timestamp DEFAULT now() NOT NULL
8 | );
9 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0008_omniscient_mongu.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "credentials" DROP COLUMN IF EXISTS "api_key";
--------------------------------------------------------------------------------
/control-plane/drizzle/0009_cuddly_medusa.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "credentials" RENAME COLUMN "user_id" TO "organization_id";--> statement-breakpoint
2 | ALTER TABLE "credentials" DROP COLUMN IF EXISTS "environment_id";--> statement-breakpoint
3 | ALTER TABLE "jobs" DROP COLUMN IF EXISTS "environment_id";--> statement-breakpoint
4 | ALTER TABLE "machines" DROP COLUMN IF EXISTS "environment_id";
--------------------------------------------------------------------------------
/control-plane/drizzle/0010_curious_amphibian.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "credentials" RENAME TO "clusters";
--------------------------------------------------------------------------------
/control-plane/drizzle/0011_thin_preak.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "machines" ADD COLUMN "ip" varchar(1024);--> statement-breakpoint
2 | ALTER TABLE "machines" ADD COLUMN "cluster_id" varchar NOT NULL;
--------------------------------------------------------------------------------
/control-plane/drizzle/0012_manual_make.sql:
--------------------------------------------------------------------------------
1 | -- fill NULL cluster_id in the table machines with -1
2 | UPDATE machines SET cluster_id = '-1' WHERE cluster_id IS NULL;
3 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0013_bitter_lily_hollister.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "machines" ALTER COLUMN "cluster_id" SET NOT NULL;
--------------------------------------------------------------------------------
/control-plane/drizzle/0014_kind_mercury.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "clusters" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint
2 | ALTER TABLE "jobs" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint
3 | ALTER TABLE "jobs" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint
4 | ALTER TABLE "machines" ALTER COLUMN "last_ping_at" SET DATA TYPE timestamp with time zone;
--------------------------------------------------------------------------------
/control-plane/drizzle/0015_flimsy_microbe.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD COLUMN "timed_out_at" timestamp with time zone DEFAULT now() + interval '300 seconds';--> statement-breakpoint
2 | ALTER TABLE "jobs" ADD COLUMN "timeout_interval" integer DEFAULT 300;
--------------------------------------------------------------------------------
/control-plane/drizzle/0016_unique_wallflower.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "clusters" ADD COLUMN "wake_up_config" json;
--------------------------------------------------------------------------------
/control-plane/drizzle/0017_purple_captain_stacy.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "users" (
2 | "id" varchar(1024) PRIMARY KEY NOT NULL,
3 | "email" varchar(1024) NOT NULL,
4 | "created_at" timestamp with time zone DEFAULT now() NOT NULL
5 | );
6 | --> statement-breakpoint
7 | ALTER TABLE "clusters" ALTER COLUMN "organization_id" DROP NOT NULL;--> statement-breakpoint
8 | ALTER TABLE "clusters" ADD COLUMN "description" varchar(1024);--> statement-breakpoint
9 | ALTER TABLE "clusters" ADD COLUMN "owner_id" varchar;--> statement-breakpoint
10 | DO $$ BEGIN
11 | ALTER TABLE "clusters" ADD CONSTRAINT "clusters_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
12 | EXCEPTION
13 | WHEN duplicate_object THEN null;
14 | END $$;
15 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0018_spooky_sasquatch.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "clusters" DROP CONSTRAINT "clusters_owner_id_users_id_fk";
2 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0019_early_silver_sable.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE "users";--> statement-breakpoint
2 | ALTER TABLE "jobs" ADD COLUMN "resulted_at" timestamp with time zone;--> statement-breakpoint
3 | ALTER TABLE "jobs" ADD COLUMN "function_execution_time_ms" integer;--> statement-breakpoint
4 | ALTER TABLE "jobs" ADD COLUMN IF NOT EXISTS "service" varchar(1024);
5 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0020_third_lockjaw.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" DROP CONSTRAINT "jobs_pkey";--> statement-breakpoint
2 | ALTER TABLE "jobs" ADD CONSTRAINT "jobs_owner_hash_target_fn_idempotency_key" PRIMARY KEY("owner_hash","target_fn","idempotency_key");
--------------------------------------------------------------------------------
/control-plane/drizzle/0021_brave_eternals.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "services" (
2 | "cluster_id" varchar NOT NULL,
3 | "machine_id" varchar NOT NULL,
4 | "service" varchar(1024) NOT NULL,
5 | "definition" json NOT NULL
6 | );
7 | --> statement-breakpoint
8 | DO $$ BEGIN
9 | ALTER TABLE "services" ADD CONSTRAINT "services_cluster_id_clusters_id_fk" FOREIGN KEY ("cluster_id") REFERENCES "clusters"("id") ON DELETE no action ON UPDATE no action;
10 | EXCEPTION
11 | WHEN duplicate_object THEN null;
12 | END $$;
13 | --> statement-breakpoint
14 | DO $$ BEGIN
15 | ALTER TABLE "services" ADD CONSTRAINT "services_machine_id_machines_id_fk" FOREIGN KEY ("machine_id") REFERENCES "machines"("id") ON DELETE no action ON UPDATE no action;
16 | EXCEPTION
17 | WHEN duplicate_object THEN null;
18 | END $$;
19 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0022_bizarre_princess_powerful.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "services" DROP CONSTRAINT "services_machine_id_machines_id_fk";
2 | --> statement-breakpoint
3 | ALTER TABLE "services" DROP COLUMN IF EXISTS "machine_id";--> statement-breakpoint
4 | ALTER TABLE "services" ADD CONSTRAINT "services_cluster_id_service" PRIMARY KEY("cluster_id","service");
--------------------------------------------------------------------------------
/control-plane/drizzle/0023_lazy_dakota_north.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ALTER COLUMN "service" SET NOT NULL;--> statement-breakpoint
2 | ALTER TABLE "jobs" ADD COLUMN "cache_key" varchar(1024);
--------------------------------------------------------------------------------
/control-plane/drizzle/0024_thin_black_bolt.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" RENAME COLUMN "remaining" TO "remaining_attempts";--> statement-breakpoint
2 | ALTER TABLE "jobs" ALTER COLUMN "timeout_interval" DROP DEFAULT;--> statement-breakpoint
3 | ALTER TABLE "jobs" ADD COLUMN "last_retrieved_at" timestamp with time zone;--> statement-breakpoint
4 | ALTER TABLE "jobs" DROP COLUMN IF EXISTS "timed_out_at";
--------------------------------------------------------------------------------
/control-plane/drizzle/0025_hot_shinobi_shaw.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" RENAME COLUMN "timeout_interval" TO "timeout_interval_seconds";
--------------------------------------------------------------------------------
/control-plane/drizzle/0026_freezing_clint_barton.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD CONSTRAINT "jobs_id_unique" UNIQUE("id");
2 | --> statement-breakpoint
3 | CREATE TABLE IF NOT EXISTS "events" (
4 | "id" varchar(1024) PRIMARY KEY NOT NULL,
5 | "cluster_id" varchar,
6 | "type" varchar(1024),
7 | "job_id" varchar(1024),
8 | "machine_id" varchar(1024),
9 | "service" varchar(1024),
10 | "created_at" timestamp with time zone NOT NULL,
11 | "meta" json
12 | );
13 | --> statement-breakpoint
14 | DO $$ BEGIN
15 | ALTER TABLE "events" ADD CONSTRAINT "events_cluster_id_clusters_id_fk" FOREIGN KEY ("cluster_id") REFERENCES "clusters"("id") ON DELETE no action ON UPDATE no action;
16 | EXCEPTION
17 | WHEN duplicate_object THEN null;
18 | END $$;
19 | --> statement-breakpoint
20 | DO $$ BEGIN
21 | ALTER TABLE "events" ADD CONSTRAINT "events_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "jobs"("id") ON DELETE no action ON UPDATE no action;
22 | EXCEPTION
23 | WHEN duplicate_object THEN null;
24 | END $$;
25 | --> statement-breakpoint
26 | DO $$ BEGIN
27 | ALTER TABLE "events" ADD CONSTRAINT "events_machine_id_machines_id_fk" FOREIGN KEY ("machine_id") REFERENCES "machines"("id") ON DELETE no action ON UPDATE no action;
28 | EXCEPTION
29 | WHEN duplicate_object THEN null;
30 | END $$;
--------------------------------------------------------------------------------
/control-plane/drizzle/0027_lyrical_ken_ellis.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "events" ALTER COLUMN "cluster_id" SET NOT NULL;--> statement-breakpoint
2 | ALTER TABLE "events" ALTER COLUMN "type" SET NOT NULL;
--------------------------------------------------------------------------------
/control-plane/drizzle/0028_parallel_karen_page.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "events" ALTER COLUMN "created_at" SET DATA TYPE timestamp (3) with time zone;
--------------------------------------------------------------------------------
/control-plane/drizzle/0029_sad_thunderbird.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "events" ALTER COLUMN "created_at" SET DATA TYPE timestamp (6) with time zone;
--------------------------------------------------------------------------------
/control-plane/drizzle/0030_omniscient_jamie_braddock.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "clusters" ADD COLUMN IF NOT EXISTS "cloud_enabled" boolean DEFAULT false;
--------------------------------------------------------------------------------
/control-plane/drizzle/0031_misty_slipstream.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "deployments" (
2 | "id" varchar(1024) PRIMARY KEY NOT NULL,
3 | "cluster_id" varchar NOT NULL,
4 | "service" varchar(1024) NOT NULL,
5 | "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
6 | "package_upload_path" varchar(1024) NOT NULL,
7 | "definition_upload_url" varchar(1024) NOT NULL,
8 | "status" text DEFAULT 'uploading' NOT NULL
9 | );
10 | --> statement-breakpoint
11 | DO $$ BEGIN
12 | ALTER TABLE "deployments" ADD CONSTRAINT "deployments_cluster_id_clusters_id_fk" FOREIGN KEY ("cluster_id") REFERENCES "clusters"("id") ON DELETE no action ON UPDATE no action;
13 | EXCEPTION
14 | WHEN duplicate_object THEN null;
15 | END $$;
16 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0032_clumsy_gressill.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "deployment_provider_config" (
2 | "provider" varchar(1024) PRIMARY KEY NOT NULL,
3 | "config" json
4 | );
5 | --> statement-breakpoint
6 | ALTER TABLE "deployments" ADD COLUMN "meta" json;
7 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0033_high_komodo.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "clusters" ADD COLUMN "predictive_retries_enabled" boolean DEFAULT false;
--------------------------------------------------------------------------------
/control-plane/drizzle/0034_real_big_bertha.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "deployments" ADD COLUMN "provider" text NOT NULL;--> statement-breakpoint
2 | ALTER TABLE "services" ADD COLUMN "deployment_provider" text;
--------------------------------------------------------------------------------
/control-plane/drizzle/0035_eminent_fantastic_four.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD COLUMN "predicted_to_be_retryable" boolean;--> statement-breakpoint
2 | ALTER TABLE "jobs" ADD COLUMN "predicted_to_be_retryable_reason" text;
--------------------------------------------------------------------------------
/control-plane/drizzle/0036_ambiguous_human_robot.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "deployment_notifications" (
2 | "id" varchar(1024) PRIMARY KEY NOT NULL,
3 | "deployment_id" varchar(1024) NOT NULL,
4 | "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL
5 | );
6 | --> statement-breakpoint
7 | ALTER TABLE "jobs" ADD COLUMN "deployment_id" varchar(1024);--> statement-breakpoint
8 | ALTER TABLE "machines" ADD COLUMN "deployment_id" varchar;--> statement-breakpoint
9 | DO $$ BEGIN
10 | ALTER TABLE "deployment_notifications" ADD CONSTRAINT "deployment_notifications_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "deployments"("id") ON DELETE no action ON UPDATE no action;
11 | EXCEPTION
12 | WHEN duplicate_object THEN null;
13 | END $$;
14 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0037_empty_molecule_man.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "events" ADD COLUMN "deployment_id" varchar(1024);--> statement-breakpoint
2 | DO $$ BEGIN
3 | ALTER TABLE "events" ADD CONSTRAINT "events_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "deployments"("id") ON DELETE no action ON UPDATE no action;
4 | EXCEPTION
5 | WHEN duplicate_object THEN null;
6 | END $$;
7 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0038_woozy_tiger_shark.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD COLUMN IF NOT EXISTS "predictive_retry_count" integer DEFAULT 0;
--------------------------------------------------------------------------------
/control-plane/drizzle/0039_puzzling_vampiro.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ALTER COLUMN "remaining_attempts" DROP DEFAULT;--> statement-breakpoint
2 | ALTER TABLE "jobs" ALTER COLUMN "remaining_attempts" SET NOT NULL;--> statement-breakpoint
3 | ALTER TABLE "clusters" ADD COLUMN "retry_on_stall_enabled" boolean DEFAULT true NOT NULL;
--------------------------------------------------------------------------------
/control-plane/drizzle/0040_mean_jazinda.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ADD COLUMN "executing_machine_id" text;--> statement-breakpoint
2 | ALTER TABLE "machines" ADD COLUMN "status" text DEFAULT 'active';--> statement-breakpoint
3 | ALTER TABLE "jobs" DROP COLUMN IF EXISTS "machine_type";
--------------------------------------------------------------------------------
/control-plane/drizzle/0041_youthful_steve_rogers.sql:
--------------------------------------------------------------------------------
1 | DELETE FROM "events";
2 | DELETE FROM "machines";
3 | ALTER TABLE "events" DROP CONSTRAINT IF EXISTS "events_machine_id_machines_id_fk";
4 |
5 | --> statement-breakpoint
6 |
7 | ALTER TABLE "machines" ADD CONSTRAINT "machines_id_cluster_id_u" UNIQUE("id","cluster_id");
8 |
9 | --> statement-breakpoint
10 |
11 | ALTER TABLE "machines" DROP CONSTRAINT IF EXISTS "machines_pkey" CASCADE;--> statement-breakpoint
12 | DO $$ BEGIN
13 | ALTER TABLE "events" ADD CONSTRAINT "events_machine_id_cluster_id_machines_id_cluster_id_fk" FOREIGN KEY ("machine_id","cluster_id") REFERENCES "machines"("id","cluster_id") ON DELETE no action ON UPDATE no action;
14 | EXCEPTION
15 | WHEN duplicate_object THEN null;
16 | END $$;
17 |
18 | --> statement-breakpoint
19 |
20 | ALTER TABLE "machines" ADD CONSTRAINT "machines_id_cluster_id" PRIMARY KEY("id","cluster_id");
21 |
22 |
23 | --> statement-breakpoint
24 |
25 | ALTER TABLE "machines" DROP CONSTRAINT IF EXISTS "machines_id_cluster_id" CASCADE;
26 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0042_kind_lester.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "asset_uploads" (
2 | "id" varchar(1024) PRIMARY KEY NOT NULL,
3 | "type" text NOT NULL,
4 | "bucket" varchar(1024) NOT NULL,
5 | "key" varchar(1024) NOT NULL,
6 | "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL
7 | );
8 | --> statement-breakpoint
9 | ALTER TABLE "deployments" ADD COLUMN "asset_upload_id" varchar(1024) NOT NULL;--> statement-breakpoint
10 | DO $$ BEGIN
11 | ALTER TABLE "deployments" ADD CONSTRAINT "deployments_asset_upload_id_asset_uploads_id_fk" FOREIGN KEY ("asset_upload_id") REFERENCES "asset_uploads"("id") ON DELETE no action ON UPDATE no action;
12 | EXCEPTION
13 | WHEN duplicate_object THEN null;
14 | END $$;
15 | --> statement-breakpoint
16 | ALTER TABLE "deployments" DROP COLUMN IF EXISTS "package_upload_path";--> statement-breakpoint
17 | ALTER TABLE "deployments" DROP COLUMN IF EXISTS "definition_upload_url";
--------------------------------------------------------------------------------
/control-plane/drizzle/0043_closed_weapon_omega.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "client_library_versions" (
2 | "id" varchar(1024) NOT NULL,
3 | "cluster_id" varchar NOT NULL,
4 | "version" varchar(1024) NOT NULL,
5 | "asset_upload_id" varchar(1024),
6 | CONSTRAINT client_library_versions_cluster_id_id PRIMARY KEY("cluster_id","id")
7 | );
8 | --> statement-breakpoint
9 | ALTER TABLE "deployments" ALTER COLUMN "asset_upload_id" DROP NOT NULL;--> statement-breakpoint
10 | DO $$ BEGIN
11 | ALTER TABLE "client_library_versions" ADD CONSTRAINT "client_library_versions_cluster_id_clusters_id_fk" FOREIGN KEY ("cluster_id") REFERENCES "clusters"("id") ON DELETE no action ON UPDATE no action;
12 | EXCEPTION
13 | WHEN duplicate_object THEN null;
14 | END $$;
15 | --> statement-breakpoint
16 | DO $$ BEGIN
17 | ALTER TABLE "client_library_versions" ADD CONSTRAINT "client_library_versions_asset_upload_id_asset_uploads_id_fk" FOREIGN KEY ("asset_upload_id") REFERENCES "asset_uploads"("id") ON DELETE no action ON UPDATE no action;
18 | EXCEPTION
19 | WHEN duplicate_object THEN null;
20 | END $$;
21 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0044_fancy_landau.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" DROP CONSTRAINT "jobs_owner_hash_target_fn_idempotency_key";--> statement-breakpoint
2 | ALTER TABLE "jobs" DROP COLUMN IF EXISTS "idempotency_key";--> statement-breakpoint
3 | ALTER TABLE "jobs" ADD CONSTRAINT "jobs_owner_hash_target_fn_id" PRIMARY KEY("owner_hash","target_fn","id");
--------------------------------------------------------------------------------
/control-plane/drizzle/0045_icy_kid_colt.sql:
--------------------------------------------------------------------------------
1 | UPDATE "jobs" SET "timeout_interval_seconds" = 3600 WHERE "timeout_interval_seconds" IS NULL;
2 |
3 | ALTER TABLE "jobs" ALTER COLUMN "timeout_interval_seconds" SET DEFAULT 3600;--> statement-breakpoint
4 | ALTER TABLE "jobs" ALTER COLUMN "timeout_interval_seconds" SET NOT NULL;
--------------------------------------------------------------------------------
/control-plane/drizzle/0046_needy_brood.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "predictive_retries_cache" (
2 | "error_name" varchar(1024) NOT NULL,
3 | "error_message" varchar(1024) NOT NULL,
4 | "retryable" boolean NOT NULL,
5 | "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
6 | CONSTRAINT predictive_retries_cache_error_name_error_message PRIMARY KEY("error_name","error_message")
7 | );
8 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0047_certain_penance.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "services" RENAME COLUMN "deployment_provider" TO "preferred_deployment_provider";
--------------------------------------------------------------------------------
/control-plane/drizzle/0048_awesome_joystick.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" ALTER COLUMN "timeout_interval_seconds" SET DEFAULT 300;--> statement-breakpoint
2 | ALTER TABLE "jobs" ADD COLUMN "predictive_retry_enabled" boolean DEFAULT false;--> statement-breakpoint
3 | ALTER TABLE "clusters" DROP COLUMN IF EXISTS "retry_on_stall_enabled";
--------------------------------------------------------------------------------
/control-plane/drizzle/0049_gray_ben_grimm.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "jobs" DROP CONSTRAINT "jobs_owner_hash_target_fn_id";--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "idx1" ON "jobs" ("owner_hash","service","status");--> statement-breakpoint
3 | CREATE INDEX IF NOT EXISTS "idx2" ON "jobs" ("owner_hash","service","target_fn","status");--> statement-breakpoint
4 | ALTER TABLE "jobs" ADD CONSTRAINT "jobs_owner_hash_id" PRIMARY KEY("owner_hash","id");
--------------------------------------------------------------------------------
/control-plane/drizzle/0050_slimy_hulk.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "cluster_access_points" (
2 | "cluster_id" varchar NOT NULL,
3 | "name" varchar(1024) NOT NULL,
4 | "allowed_services_csv" text NOT NULL,
5 | "token" varchar(1024) NOT NULL,
6 | "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
7 | "updated_at" timestamp (6) with time zone NOT NULL
8 | );
9 | --> statement-breakpoint
10 | DO $$ BEGIN
11 | ALTER TABLE "cluster_access_points" ADD CONSTRAINT "cluster_access_points_cluster_id_clusters_id_fk" FOREIGN KEY ("cluster_id") REFERENCES "clusters"("id") ON DELETE no action ON UPDATE no action;
12 | EXCEPTION
13 | WHEN duplicate_object THEN null;
14 | END $$;
15 |
--------------------------------------------------------------------------------
/control-plane/drizzle/0051_loving_phil_sheldon.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "cluster_access_points" ADD CONSTRAINT "cluster_access_points_cluster_id_name" PRIMARY KEY("cluster_id","name");
--------------------------------------------------------------------------------
/control-plane/drizzle/0052_swift_microbe.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "services" ADD COLUMN "types" text;
--------------------------------------------------------------------------------
/control-plane/drizzle/0053_broken_zaran.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "services" ALTER COLUMN "definition" DROP NOT NULL;--> statement-breakpoint
2 | ALTER TABLE "services" ADD COLUMN "json_schema" json;--> statement-breakpoint
3 | ALTER TABLE "services" DROP COLUMN IF EXISTS "types";
--------------------------------------------------------------------------------
/control-plane/fly.toml:
--------------------------------------------------------------------------------
1 | app = "differential-core"
2 | primary_region = "lax"
3 |
4 | [build]
5 |
6 | [http_service]
7 | internal_port = 4000
8 | force_https = true
9 | auto_stop_machines = true
10 | auto_start_machines = true
11 | min_machines_running = 1
12 | processes = ["app"]
13 |
14 | [http_service.concurrency]
15 | type = "requests"
16 | hard_limit = 500
17 | soft_limit = 400
18 |
19 | [[http_service.checks]]
20 | grace_period = "2s"
21 | interval = "2s"
22 | method = "GET"
23 | timeout = "2s"
24 | path = "/live"
25 |
26 | [metrics]
27 | port = 9091
28 | path = "/metrics"
29 |
--------------------------------------------------------------------------------
/control-plane/internal.md:
--------------------------------------------------------------------------------
1 | # differential-core
2 |
3 | ## How to
4 |
5 | 1. Generating migrations
6 |
7 | ```sh
8 | DATABASE_SSL_DISABLED="true" DATABASE_URL="postgres://" npm run migrations
9 | ```
--------------------------------------------------------------------------------
/control-plane/kubernetes.dev.yaml:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/control-plane/kubernetes.dev.yaml
--------------------------------------------------------------------------------
/control-plane/media/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/control-plane/media/logo.png
--------------------------------------------------------------------------------
/control-plane/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@differentialhq/control-plane",
3 | "version": "0.0.16",
4 | "description": "",
5 | "scripts": {
6 | "test": "tsx src/utilities/migrate.ts && jest src --runInBand --setupFiles dotenv/config --forceExit --detectOpenHandles",
7 | "test:dev": "tsx src/utilities/migrate.ts && jest --runInBand --watch src --setupFiles dotenv/config --forceExit --onlyChanged",
8 | "dev": "tsx src/utilities/migrate.ts && nodemon src/index.ts",
9 | "migrations": "npx drizzle-kit generate:pg --schema src/modules/data.ts",
10 | "build": "tsc",
11 | "start": "node dist/utilities/migrate.js && node dist/index.js",
12 | "deploy": "git add . && git commit -m 'update' && git push origin main && fly deploy"
13 | },
14 | "author": "",
15 | "license": "ISC",
16 | "dependencies": {
17 | "@aws-sdk/client-cloudformation": "^3.529.1",
18 | "@aws-sdk/client-cloudwatch-logs": "^3.540.0",
19 | "@aws-sdk/client-lambda": "^3.504.0",
20 | "@aws-sdk/client-s3": "^3.496.0",
21 | "@aws-sdk/client-sns": "^3.529.1",
22 | "@aws-sdk/s3-request-presigner": "^3.496.0",
23 | "@babel/preset-env": "^7.22.10",
24 | "@babel/preset-typescript": "^7.22.11",
25 | "@fastify/cors": "^8.5.0",
26 | "@langchain/core": "^0.1.61",
27 | "@langchain/openai": "^0.0.28",
28 | "@sentry/node": "^7.93.0",
29 | "@sentry/profiling-node": "^1.3.5",
30 | "@ts-rest/core": "^3.27.0",
31 | "@ts-rest/fastify": "^3.27.0",
32 | "@types/jest": "^29.5.4",
33 | "@types/semver": "^7.5.8",
34 | "axios": "^1.6.2",
35 | "dotenv": "^16.3.1",
36 | "drizzle-kit": "^0.19.12",
37 | "drizzle-orm": "^0.27.2",
38 | "fastify": "^4.21.0",
39 | "jest": "^29.6.4",
40 | "json-schema-to-zod": "^2.1.0",
41 | "jsonwebtoken": "^9.0.2",
42 | "jwks-rsa": "^3.1.0",
43 | "langchain": "^0.1.36",
44 | "msgpackr": "^1.10.1",
45 | "node-cache": "^5.1.2",
46 | "nodemon": "^3.0.1",
47 | "pg": "^8.11.2",
48 | "prom-client": "^15.1.0",
49 | "semver": "^7.6.0",
50 | "sns-validator": "^0.3.5",
51 | "ts-node": "^10.9.1",
52 | "typescript": "^5.1.6",
53 | "ulid": "^2.3.0",
54 | "winston": "^3.12.0",
55 | "zod": "^3.22.4"
56 | },
57 | "devDependencies": {
58 | "@types/jsonwebtoken": "^9.0.2",
59 | "@types/pg": "^8.10.2",
60 | "@types/sns-validator": "^0.3.3",
61 | "@types/ws": "^8.5.5",
62 | "tsx": "^4.7.0"
63 | },
64 | "private": true,
65 | "jest": {
66 | "testTimeout": 20000
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/control-plane/postgres.dev.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: PersistentVolumeClaim
3 | metadata:
4 | name: postgres-pv-claim
5 | spec:
6 | accessModes:
7 | - ReadWriteOnce
8 | resources:
9 | requests:
10 | storage: 1Gi
11 |
12 | ---
13 | apiVersion: v1
14 | kind: Service
15 | metadata:
16 | name: postgres
17 | spec:
18 | type: ClusterIP
19 | ports:
20 | - port: 5432
21 | selector:
22 | app: postgres
23 |
24 | ---
25 | apiVersion: apps/v1
26 | kind: Deployment
27 | metadata:
28 | name: postgres
29 | spec:
30 | selector:
31 | matchLabels:
32 | app: postgres
33 | template:
34 | metadata:
35 | labels:
36 | app: postgres
37 | spec:
38 | containers:
39 | - name: postgres
40 | image: postgres:latest
41 | env:
42 | - name: POSTGRES_DB
43 | value: postgres
44 | - name: POSTGRES_USER
45 | value: postgres
46 | - name: POSTGRES_PASSWORD
47 | value: postgres
48 | ports:
49 | - containerPort: 5432
50 | volumeMounts:
51 | - mountPath: /var/lib/postgresql/data
52 | name: postgres-storage
53 | volumes:
54 | - name: postgres-storage
55 | persistentVolumeClaim:
56 | claimName: postgres-pv-claim
57 |
--------------------------------------------------------------------------------
/control-plane/src/modules/agents/agent.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/control-plane/src/modules/agents/agent.ts
--------------------------------------------------------------------------------
/control-plane/src/modules/cluster-activity.ts:
--------------------------------------------------------------------------------
1 | import Cache from "node-cache";
2 |
3 | const cache = new Cache({ stdTTL: 60, maxKeys: 1000 });
4 |
5 | export const setClusterActivityToHigh = (clusterId: string) => {
6 | cache.set(clusterId, true);
7 | };
8 |
9 | export const isClusterActivityHigh = (clusterId: string) => {
10 | return cache.get(clusterId) === true;
11 | };
12 |
--------------------------------------------------------------------------------
/control-plane/src/modules/cluster.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import NodeCache from "node-cache";
3 | import * as data from "./data";
4 |
5 | const cache = new NodeCache({ stdTTL: 60, checkperiod: 65, maxKeys: 5000 });
6 |
7 | export type OperationalCluster = {
8 | predictiveRetriesEnabled: boolean | null;
9 | cloudEnabled: boolean | null;
10 | };
11 |
12 | export const operationalCluster = async (
13 | clusterId: string,
14 | ): Promise => {
15 | const cached = cache.get(clusterId);
16 |
17 | if (cached) {
18 | return cached;
19 | }
20 |
21 | const results = await data.db
22 | .select({
23 | predictiveRetriesEnabled: data.clusters.predictive_retries_enabled,
24 | cloudEnabled: data.clusters.cloud_enabled,
25 | })
26 | .from(data.clusters)
27 | .where(eq(data.clusters.id, clusterId));
28 |
29 | if (results.length === 0) {
30 | throw new Error(`Cluster not found: ${clusterId}`);
31 | }
32 |
33 | cache.set(clusterId, results[0]);
34 |
35 | return results[0];
36 | };
37 |
38 | export const getCluster = async (clusterId: string) => {
39 | const [cluster] = await data.db
40 | .select({
41 | id: data.clusters.id,
42 | apiSecret: data.clusters.api_secret,
43 | })
44 | .from(data.clusters)
45 | .where(eq(data.clusters.id, clusterId));
46 |
47 | if (!cluster) {
48 | throw new Error(`Cluster not found: ${clusterId}`);
49 | }
50 |
51 | return cluster;
52 | };
53 |
--------------------------------------------------------------------------------
/control-plane/src/modules/cron.ts:
--------------------------------------------------------------------------------
1 | // a naive cron implementation which will consume from a CDC later
2 |
3 | const intervals: NodeJS.Timeout[] = [];
4 |
5 | export const registerCron = async (
6 | fn: () => Promise,
7 | { interval }: { interval: number },
8 | ) => {
9 | const intervalId = setInterval(fn, interval);
10 | intervals.push(intervalId);
11 | };
12 |
13 | process.on("beforeExit", () => {
14 | intervals.forEach((intervalId) => {
15 | clearInterval(intervalId);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/control-plane/src/modules/deployment/cfn-manager.test.ts:
--------------------------------------------------------------------------------
1 | import { deploymentResultFromNotification } from "./cfn-manager";
2 |
3 | describe("deploymentResultFromNotification", () => {
4 | const stackId = "arn:aws:cloudformation:xxxx";
5 | const clientRequestToken = "xxxx";
6 | it("throws if status is missing", () => {
7 | expect(() => deploymentResultFromNotification({})).toThrow();
8 | });
9 |
10 | it.each(["CREATE_COMPLETE", "UPDATE_COMPLETE"])(
11 | "%s status is treated as a success",
12 | (status) => {
13 | const result = deploymentResultFromNotification({
14 | ResourceStatus: status,
15 | ResourceStatusReason: "",
16 | ClientRequestToken: clientRequestToken,
17 | StackId: stackId,
18 | });
19 | expect(result).toMatchObject({
20 | success: true,
21 | pending: false,
22 | status,
23 | stackId,
24 | clientRequestToken,
25 | });
26 | },
27 | );
28 |
29 | it.each([
30 | "UPDATE_ROLLBACK_FAILED",
31 | "ROLLBACK_FAILED",
32 | "UPDATE_ROLLBACK_COMPLETE",
33 | "DELETE_COMPLETE",
34 | ])("%s status is treated as a failure", (status) => {
35 | const result = deploymentResultFromNotification({
36 | ResourceStatus: status,
37 | ResourceStatusReason: "Something went wrong!",
38 | ClientRequestToken: clientRequestToken,
39 | StackId: stackId,
40 | });
41 | expect(result).toMatchObject({
42 | success: false,
43 | pending: false,
44 | reason: "Something went wrong!",
45 | status,
46 | stackId,
47 | clientRequestToken,
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/control-plane/src/modules/deployment/deployment-provider.ts:
--------------------------------------------------------------------------------
1 | import { ZodSchema } from "zod";
2 | import { Deployment } from "./deployment";
3 | import * as data from "../data";
4 | import { eq } from "drizzle-orm";
5 | import { MockProvider } from "./mock-deployment-provider";
6 | import { LambdaCfnProvider } from "./lambda-cfn-provider";
7 |
8 | const mockProvider = new MockProvider();
9 | const lambdaProvider = new LambdaCfnProvider();
10 |
11 | export const getDeploymentProvider = (provider: string): DeploymentProvider => {
12 | switch (provider) {
13 | case "lambda":
14 | return lambdaProvider;
15 | case "mock":
16 | return mockProvider;
17 | default:
18 | throw new Error(`Unknown provider ${provider}`);
19 | }
20 | };
21 |
22 | export const fetchConfig = async (
23 | provider: DeploymentProvider,
24 | ): Promise => {
25 | const config = await data.db
26 | .select({
27 | config: data.deploymentProvividerConfig.config,
28 | })
29 | .from(data.deploymentProvividerConfig)
30 | .where(eq(data.deploymentProvividerConfig.provider, provider.name()));
31 |
32 | if (config.length === 0) {
33 | throw new Error(`No configuration found for provider ${provider.name()}`);
34 | }
35 |
36 | return provider.schema().parse(config[0].config);
37 | };
38 |
39 | export interface DeploymentProvider {
40 | name: () => string;
41 | schema: () => ZodSchema;
42 | // Create a new deployment
43 | create: (deployment: Deployment) => Promise;
44 | // Update an existing deployment
45 | update: (deployment: Deployment) => Promise;
46 | // Notify the provider of a a new job
47 | notify: (
48 | deployment: Deployment,
49 | pendingJobs: number,
50 | runningMachines: number,
51 | ) => Promise;
52 | getLogs: (
53 | deployment: Deployment,
54 | options: {
55 | start?: Date;
56 | end?: Date;
57 | filter?: string;
58 | next?: string;
59 | },
60 | ) => Promise<{ message: string }[]>;
61 | // How frequently the provider should be notified of new jobs
62 | minimumNotificationInterval: () => number;
63 | }
64 |
--------------------------------------------------------------------------------
/control-plane/src/modules/deployment/mock-deployment-provider.ts:
--------------------------------------------------------------------------------
1 | import { ZodSchema, z } from "zod";
2 | import { Deployment } from "./deployment";
3 | import { DeploymentProvider } from "./deployment-provider";
4 | import { logger } from "../../utilities/logger";
5 |
6 | export class MockProvider implements DeploymentProvider {
7 | public name(): string {
8 | return "mock";
9 | }
10 |
11 | public schema(): ZodSchema<{}> {
12 | return z.object({});
13 | }
14 |
15 | public minimumNotificationInterval(): number {
16 | return 10000;
17 | }
18 |
19 | public async create(deployment: Deployment): Promise {
20 | logger.info("Would create new deployment", {
21 | deployment,
22 | });
23 | }
24 |
25 | public async update(deployment: Deployment): Promise {
26 | logger.info("Would update existing deployment", {
27 | deployment,
28 | });
29 | }
30 |
31 | public async notify(
32 | deployment: Deployment,
33 | pendingJobs: number,
34 | runningMachines: number,
35 | ): Promise {
36 | logger.info("Would notify provider of new jobs", {
37 | deployment,
38 | pendingJobs,
39 | runningMachines,
40 | });
41 | }
42 |
43 | public async getLogs(): Promise<{ message: string }[]> {
44 | return [{ message: "Test log" }];
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/control-plane/src/modules/health.ts:
--------------------------------------------------------------------------------
1 | import { db } from "./data";
2 | import { sql } from "drizzle-orm";
3 |
4 | export const isOk = () =>
5 | db
6 | .execute(sql`SELECT 1`)
7 | .then(() => true)
8 | .catch(() => false);
9 |
--------------------------------------------------------------------------------
/control-plane/src/modules/jobs/job-metrics.ts:
--------------------------------------------------------------------------------
1 | import { Summary } from "prom-client";
2 |
3 | export const jobDurations = new Summary({
4 | name: "differential_job_operations_duration_ms",
5 | help: "Duration of job operations in milliseconds",
6 | labelNames: ["operation"],
7 | });
8 |
--------------------------------------------------------------------------------
/control-plane/src/modules/jwt.test.ts:
--------------------------------------------------------------------------------
1 | import * as jwt from "./jwt";
2 | import { env } from "../utilities/env";
3 |
4 | jest.mock("../utilities/env");
5 |
6 | describe("verifyManagementToken", () => {
7 | afterEach(() => {
8 | jest.resetAllMocks();
9 | });
10 |
11 | it("should verify a correct token", async () => {
12 | env.JWT_IGNORE_EXPIRATION = true;
13 |
14 | const token = `eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yYTF3ZjlWUWhwbFpXdzZkYlJ1M3NSWTN2RkgiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJleHAiOjE3MDM1Mzk1NDIsImlhdCI6MTcwMzUzOTQ4MiwiaXNzIjoiaHR0cHM6Ly9odW1hbmUtZ3JvdXBlci05OC5jbGVyay5hY2NvdW50cy5kZXYiLCJuYmYiOjE3MDM1Mzk0NzIsInNpZCI6InNlc3NfMmExeEU4OG02bkVSZmlkQnUyZUxDbjF6dkRxIiwic3ViIjoidXNlcl8yYTF4RTVwdlNUUGxsVng2aTh3Q0E3cFZWSEoifQ.RsNtbucx6zpEemvKlgheDJ4n_ygNKpwiBBVsH_Vnb_jV1z4g7XMGYYmuKlMJLqSqNj2jc96J_VyiULcDlCC2ylEFwuIsPSn8qgsF6M-7LIUsZ9IRosT3m3K0s-wWWXBBOKZsxqD38aUh6A8AKwTNxEgs_JnWmFCdzvlftPmRNd0_QFiOiwTX1PZk3XFG2ETM8t-t9Q8AVf0T09kdrZ6CZphV93VDmCSh7zBouMtitO5hU58GERIomEagRRI-NV4310WT8YNt-XXiG8hIcejIw6wRGyYDdHoV66QJLF9cs8HRjxHLHqVqpJu8QL8yRj6wzFVtj22fE_guYSD1S0bMPQ`;
15 |
16 | const result = await jwt.verifyManagementToken({ managementToken: token });
17 |
18 | expect(result).toEqual({
19 | userId: "user_2a1xE5pvSTPllVx6i8wCA7pVVHJ",
20 | });
21 | });
22 |
23 | it("should throw on a malformed token", async () => {
24 | const token = `1234`;
25 |
26 | await expect(
27 | jwt.verifyManagementToken({ managementToken: token }),
28 | ).rejects.toThrowError("jwt malformed");
29 | });
30 |
31 | it("should accept a correct management secret", async () => {
32 | const token =
33 | "sk_management_98a7oysidtghfkjbaslnd;fuays87otdiygfukahjsbdlf;a;soihudyfgajvshkbdlnf;asdfh";
34 |
35 | env.MANAGEMENT_SECRET = token;
36 |
37 | const result = await jwt.verifyManagementToken({
38 | managementToken: token,
39 | });
40 |
41 | expect(result).toEqual({
42 | userId: "control-plane-administrator",
43 | });
44 | });
45 |
46 | it("should throw on an incorrect management secret", async () => {
47 | const token =
48 | "sk_management_98a7oysidtghfkjbaslnd;fuays87otdiygfukahjsbdlf;a;soihudyfgajvshkbdlnf;asdfh";
49 |
50 | env.MANAGEMENT_SECRET = token;
51 |
52 | await expect(
53 | jwt.verifyManagementToken({ managementToken: "sk_management_1234" }),
54 | ).rejects.toThrowError("Invalid token");
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/control-plane/src/modules/jwt.ts:
--------------------------------------------------------------------------------
1 | import jwt, { GetPublicKeyOrSecret } from "jsonwebtoken";
2 | import jwksClient from "jwks-rsa";
3 | import { env } from "../utilities/env";
4 |
5 | export class AuthenticationError extends Error {
6 | statusCode = 401;
7 |
8 | constructor(message: string) {
9 | super(message);
10 | this.name = "AuthenticationError";
11 | }
12 | }
13 |
14 | const client = env.JWKS_URL
15 | ? jwksClient({
16 | jwksUri: env.JWKS_URL,
17 | })
18 | : null;
19 |
20 | const getKey: GetPublicKeyOrSecret = (header, callback) => {
21 | if (!client) {
22 | return callback(
23 | new Error(
24 | "JWKS client not initialized. Probably missing JWKS_URL in env.",
25 | ),
26 | );
27 | }
28 |
29 | return client.getSigningKey(header.kid, function (err, key) {
30 | var signingKey = key?.getPublicKey();
31 | callback(err, signingKey);
32 | });
33 | };
34 |
35 | export const CONTROL_PLANE_ADMINISTRATOR = "control-plane-administrator";
36 |
37 | export const verifyManagementToken = async ({
38 | managementToken,
39 | }: {
40 | managementToken: string;
41 | }): Promise<{
42 | userId: string;
43 | }> => {
44 | const managementSecretAuthEnabled = Boolean(env.MANAGEMENT_SECRET);
45 |
46 | if (managementSecretAuthEnabled && managementToken) {
47 | const secretsMatch = managementToken === env.MANAGEMENT_SECRET;
48 |
49 | if (secretsMatch) {
50 | return {
51 | userId: CONTROL_PLANE_ADMINISTRATOR,
52 | };
53 | } else {
54 | throw new AuthenticationError("Invalid token");
55 | }
56 | }
57 |
58 | return new Promise((resolve, reject) => {
59 | jwt.verify(
60 | managementToken,
61 | getKey,
62 | {
63 | algorithms: ["RS256"],
64 | ignoreExpiration: env.JWT_IGNORE_EXPIRATION,
65 | },
66 | function (err, decoded) {
67 | if (err) {
68 | return reject(new AuthenticationError(err.message));
69 | }
70 |
71 | if (!decoded) {
72 | return reject(new AuthenticationError("No decoded token"));
73 | }
74 |
75 | if (typeof decoded.sub !== "string") {
76 | return reject(new AuthenticationError("No sub in decoded token"));
77 | }
78 |
79 | return resolve({
80 | userId: decoded.sub,
81 | });
82 | },
83 | );
84 | });
85 | };
86 |
--------------------------------------------------------------------------------
/control-plane/src/modules/management.test.ts:
--------------------------------------------------------------------------------
1 | import { getClusterSettings, setClusterSettings } from "./management";
2 | import { createOwner } from "./test/util";
3 |
4 | describe("management", () => {
5 | describe("cluster settings", () => {
6 | it("should be able to get and set cluster settings", async () => {
7 | const owner = await createOwner();
8 |
9 | const initial = await getClusterSettings(owner.clusterId);
10 |
11 | expect(initial).toStrictEqual({
12 | predictiveRetriesEnabled: false,
13 | cloudEnabled: false,
14 | });
15 |
16 | await setClusterSettings(owner.clusterId, {
17 | predictiveRetriesEnabled: true,
18 | });
19 |
20 | const updated = await getClusterSettings(owner.clusterId);
21 |
22 | expect(updated).toStrictEqual({
23 | predictiveRetriesEnabled: true,
24 | cloudEnabled: false,
25 | });
26 | });
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/control-plane/src/modules/packages/versioning.test.ts:
--------------------------------------------------------------------------------
1 | import { ulid } from "ulid";
2 | import * as data from "../data";
3 | import { createOwner } from "../test/util";
4 | import {
5 | previousVersion,
6 | incrementVersion,
7 | SemVerIncrement,
8 | } from "./versioning";
9 |
10 | describe("versioning", () => {
11 | let cluster1: string;
12 |
13 | beforeAll(async () => {
14 | cluster1 = (await createOwner()).clusterId;
15 |
16 | const versions = ["1.0.0", "1.0.1", "1.1.0", "2.9.9", "3.0.0"];
17 |
18 | const upload = await data.db
19 | .insert(data.assetUploads)
20 | .values({
21 | id: ulid(),
22 | type: "client_library",
23 | bucket: "bucket",
24 | key: "key",
25 | })
26 | .returning({
27 | id: data.assetUploads.id,
28 | });
29 |
30 | for (const version of versions) {
31 | await data.db.insert(data.clientLibraryVersions).values({
32 | id: ulid(),
33 | cluster_id: cluster1,
34 | asset_upload_id: upload[0].id,
35 | version,
36 | });
37 | }
38 | });
39 |
40 | afterEach(() => {
41 | jest.clearAllMocks();
42 | });
43 |
44 | it("should return the highest version library", async () => {
45 | const result = await previousVersion({ clusterId: cluster1 });
46 | expect(result).toBe("3.0.0");
47 | });
48 |
49 | it("should return 0.0.0 if no version exist", async () => {
50 | const result = await previousVersion({ clusterId: "nonexistent" });
51 | expect(result).toBe("0.0.0");
52 | });
53 |
54 | it.each([
55 | ["patch", "3.0.1"],
56 | ["minor", "3.1.0"],
57 | ["major", "4.0.0"],
58 | ])(`should increment version by %s`, async (increment, expected) => {
59 | const result = incrementVersion({
60 | version: "3.0.0",
61 | increment: increment as SemVerIncrement,
62 | });
63 | expect(result).toBe(expected);
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/control-plane/src/modules/packages/versioning.ts:
--------------------------------------------------------------------------------
1 | import { and, eq, isNotNull } from "drizzle-orm";
2 | import * as data from "../data";
3 |
4 | export type SemVerIncrement = "patch" | "minor" | "major";
5 |
6 | import * as semver from "semver";
7 |
8 | export const previousVersion = async ({
9 | clusterId,
10 | }: {
11 | clusterId: string;
12 | }): Promise => {
13 | const previousVersions = await data.db
14 | .select({
15 | version: data.clientLibraryVersions.version,
16 | })
17 | .from(data.clientLibraryVersions)
18 | .where(
19 | and(
20 | eq(data.clientLibraryVersions.cluster_id, clusterId),
21 | isNotNull(data.clientLibraryVersions.asset_upload_id),
22 | ),
23 | );
24 |
25 | if (previousVersions.length === 0) {
26 | return "0.0.0";
27 | }
28 |
29 | return previousVersions.map((v) => v.version).sort(semver.rcompare)[0];
30 | };
31 |
32 | export const incrementVersion = ({
33 | version,
34 | increment,
35 | }: {
36 | version: string;
37 | increment: SemVerIncrement;
38 | }): string => {
39 | const nextVersion = semver.inc(version, increment);
40 |
41 | if (!nextVersion) {
42 | throw new Error(`Could not increment version: ${version} ${increment}`);
43 | }
44 |
45 | return nextVersion;
46 | };
47 |
--------------------------------------------------------------------------------
/control-plane/src/modules/predictor/client.ts:
--------------------------------------------------------------------------------
1 | import { initClient } from "@ts-rest/core";
2 | import { contract } from "./contract";
3 | import { env } from "../../utilities/env";
4 | import { logger } from "../../utilities/logger";
5 |
6 | if (!env.PREDICTOR_API_URL) {
7 | logger.warn("PREDICTOR_API_URL is not set");
8 | }
9 |
10 | export const client = env.PREDICTOR_API_URL
11 | ? initClient(contract, {
12 | baseUrl: env.PREDICTOR_API_URL,
13 | baseHeaders: {},
14 | })
15 | : null;
16 |
--------------------------------------------------------------------------------
/control-plane/src/modules/predictor/contract.ts:
--------------------------------------------------------------------------------
1 | import { initContract } from "@ts-rest/core";
2 | import { z } from "zod";
3 |
4 | const c = initContract();
5 |
6 | export const contract = c.router({
7 | predictRetryability: {
8 | method: "POST",
9 | path: "/is-retryable",
10 | headers: z.object({
11 | authorization: z.string(),
12 | }),
13 | body: z.object({
14 | errorName: z.string(),
15 | errorMessage: z.string(),
16 | }),
17 | responses: {
18 | 200: z.object({
19 | retryable: z.boolean(),
20 | }),
21 | },
22 | },
23 | patchFunction: {
24 | method: "POST",
25 | path: "/patch-function",
26 | headers: z.object({
27 | authorization: z.string(),
28 | }),
29 | body: z.object({
30 | errorName: z.string(),
31 | errorMessage: z.string(),
32 | fn: z.string(),
33 | }),
34 | responses: {
35 | 200: z.object({
36 | patch: z.string().nullable(),
37 | }),
38 | },
39 | },
40 | live: {
41 | method: "GET",
42 | path: "/live",
43 | responses: {
44 | 200: z.object({
45 | live: z.boolean(),
46 | }),
47 | },
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/control-plane/src/modules/predictor/predictor.test.ts:
--------------------------------------------------------------------------------
1 | import msgpackr from "msgpackr";
2 | import { isRetryable } from "./predictor";
3 |
4 | describe("predictor", () => {
5 | it("should cache the prediction", async () => {
6 | const nonce = Math.random().toString(36).substring(7);
7 |
8 | const errorName = `Error${nonce}`;
9 |
10 | const status = await isRetryable(
11 | msgpackr
12 | .pack({ name: errorName, message: "This error is retryable" })
13 | .toString("base64"),
14 | );
15 |
16 | expect(status.retryable).toBe(true);
17 | expect(status.cached).toBeFalsy();
18 |
19 | const status2 = await isRetryable(
20 | msgpackr
21 | .pack({ name: errorName, message: "This error is retryable" })
22 | .toString("base64"),
23 | );
24 |
25 | expect(status2.retryable).toBe(true);
26 | expect(status2.cached).toBe(true);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/control-plane/src/modules/s3.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GetObjectCommand,
3 | PutObjectCommand,
4 | S3Client,
5 | } from "@aws-sdk/client-s3";
6 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
7 |
8 | const s3Client = new S3Client();
9 |
10 | export const getPresignedURL = async (
11 | bucket: string,
12 | key: string,
13 | ): Promise => {
14 | const command = new PutObjectCommand({
15 | Bucket: bucket,
16 | Key: key,
17 | ContentType: "application/zip",
18 | });
19 |
20 | return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
21 | };
22 |
23 | export const getObject = async ({
24 | bucket,
25 | key,
26 | }: {
27 | bucket: string;
28 | key: string;
29 | }): Promise => {
30 | const command = new GetObjectCommand({
31 | Bucket: bucket,
32 | Key: key,
33 | });
34 |
35 | const { Body } = await s3Client.send(command);
36 | if (!Body) {
37 | throw new Error("No body in S3 response");
38 | }
39 | return Body;
40 | };
41 |
--------------------------------------------------------------------------------
/control-plane/src/modules/sns.test.ts:
--------------------------------------------------------------------------------
1 | import { parseCloudFormationMessage } from "./sns";
2 |
3 | describe("parseCloudFormationMessages", () => {
4 | const testNotification = `
5 | 'StackId'='arn:aws:cloudformation:xxxx'
6 | 'ResourceStatus'='UPDATE_FAILED'
7 | 'ResourceStatusReason'='Something went wrong!'
8 | 'ClientRequestToken'='xxxx'
9 | `;
10 | it("parses the stackName", () => {
11 | const result = parseCloudFormationMessage(testNotification);
12 | expect(result.StackId).toBe("arn:aws:cloudformation:xxxx");
13 | expect(result.ResourceStatus).toBe("UPDATE_FAILED");
14 | expect(result.ResourceStatusReason).toBe("Something went wrong!");
15 | expect(result.ClientRequestToken).toBe("xxxx");
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/control-plane/src/modules/sns.ts:
--------------------------------------------------------------------------------
1 | import { ConfirmSubscriptionCommand, SNSClient } from "@aws-sdk/client-sns";
2 | import MessageValidator from "sns-validator";
3 |
4 | const validator = new MessageValidator();
5 | const sns = new SNSClient();
6 |
7 | // "'stackId'='XXXX'\n";
8 | export const parseCloudFormationMessage = (notification: string) =>
9 | notification
10 | .replace(/"/g, "")
11 | .replace(/'/g, "")
12 | .split("\n")
13 | .reduce(
14 | (acc, line) => {
15 | const [key, value] = line.split("=");
16 | acc[key] = value;
17 | return acc;
18 | },
19 | {} as Record,
20 | );
21 |
22 | export const confirmSubscription = async (options: {
23 | Token: string;
24 | TopicArn: string;
25 | }) => {
26 | await sns.send(new ConfirmSubscriptionCommand(options));
27 | };
28 |
29 | export const validateSignature = async (
30 | message: Record,
31 | ): Promise => {
32 | return new Promise((resolve, reject) =>
33 | validator.validate(message, (err: any) => {
34 | if (err) {
35 | reject(err);
36 | } else {
37 | resolve(true);
38 | }
39 | }),
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/control-plane/src/modules/test/util.ts:
--------------------------------------------------------------------------------
1 | import * as data from "../data";
2 |
3 | export const createOwner = async (params?: { clusterId?: string }) => {
4 | const clusterId = params?.clusterId || `test-cluster-${Math.random()}`;
5 |
6 | const apiSecret = `test-secret-${Math.random()}`;
7 |
8 | await data.db
9 | .insert(data.clusters)
10 | .values({
11 | id: clusterId,
12 | api_secret: apiSecret,
13 | })
14 | .execute();
15 |
16 | return { clusterId, apiSecret };
17 | };
18 |
--------------------------------------------------------------------------------
/control-plane/src/modules/util.ts:
--------------------------------------------------------------------------------
1 | import { logger } from "../utilities/logger";
2 |
3 | export const backgrounded = Promise>(
4 | fn: T,
5 | ): ((...args: Parameters) => void) => {
6 | return (...args) => {
7 | fn(...args).catch((err) => {
8 | logger.error(`Error in backgrounded function`, {
9 | funcion: fn.name,
10 | error: err,
11 | });
12 | });
13 | };
14 | };
15 |
--------------------------------------------------------------------------------
/control-plane/src/utilities/errors.ts:
--------------------------------------------------------------------------------
1 | export class NotFoundError extends Error {
2 | statusCode: number = 404;
3 |
4 | constructor(message: string) {
5 | super(message);
6 | this.name = "NotFoundError";
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/control-plane/src/utilities/invariant.ts:
--------------------------------------------------------------------------------
1 | type WithoutNullOrUndefined = T extends null | undefined ? never : T;
2 |
3 | export const invariant = (
4 | value: T,
5 | message: string
6 | ): WithoutNullOrUndefined => {
7 | if (value === null || value === undefined) {
8 | throw new Error(message);
9 | }
10 |
11 | return value as WithoutNullOrUndefined;
12 | };
13 |
--------------------------------------------------------------------------------
/control-plane/src/utilities/logger.ts:
--------------------------------------------------------------------------------
1 | import { createLogger, format, transports } from "winston";
2 | import { AsyncLocalStorage } from "async_hooks";
3 | import { env } from "../utilities/env";
4 |
5 | export const logContext = new AsyncLocalStorage();
6 |
7 | const winston = createLogger({
8 | level: env.LOG_LEVEL,
9 | format:
10 | env.NODE_ENV === "development"
11 | ? format.combine(format.colorize(), format.simple())
12 | : format.simple(),
13 | transports: [new transports.Console()],
14 | });
15 |
16 | type LogMeta = Record;
17 | type LogLevel = "error" | "warn" | "info" | "debug";
18 |
19 | const log = (level: LogLevel, message: string, meta?: LogMeta) => {
20 | const store = logContext.getStore();
21 | if (store) {
22 | winston.log(level, message, {
23 | ...meta,
24 | ...store,
25 | });
26 | } else {
27 | winston.log(level, message, {
28 | ...meta,
29 | });
30 | }
31 | };
32 |
33 | export const logger = {
34 | error: (message: string, meta?: LogMeta) => log("error", message, meta),
35 | warn: (message: string, meta?: LogMeta) => log("warn", message, meta),
36 | info: (message: string, meta?: LogMeta) => log("info", message, meta),
37 | debug: (message: string, meta?: LogMeta) => log("debug", message, meta),
38 | };
39 |
--------------------------------------------------------------------------------
/control-plane/src/utilities/migrate.ts:
--------------------------------------------------------------------------------
1 | import "./env";
2 | import { migrate } from "drizzle-orm/node-postgres/migrator";
3 | import * as data from "../modules/data";
4 | import { logger } from "./logger";
5 |
6 | (async function runMigrations() {
7 | logger.info("Migrating database...");
8 |
9 | try {
10 | await migrate(data.db, { migrationsFolder: "./drizzle" });
11 | logger.info("Database migrated successfully");
12 | } catch (e) {
13 | logger.error("Error migrating database", {
14 | error: e,
15 | });
16 | process.exit(1);
17 | }
18 |
19 | await data.pool.end();
20 | })();
21 |
--------------------------------------------------------------------------------
/control-plane/src/utilities/profiling.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/node";
2 | import { ProfilingIntegration } from "@sentry/profiling-node";
3 | import { env } from "./env";
4 |
5 | if (env.SENTRY_DSN) {
6 | Sentry.init({
7 | dsn: env.SENTRY_DSN,
8 | integrations: [new ProfilingIntegration()],
9 | tracesSampleRate: 1.0,
10 | profilesSampleRate: 1.0,
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/control-plane/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2016",
4 | "module": "commonjs",
5 | "outDir": "./dist",
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "skipLibCheck": true,
10 | "allowJs": true,
11 | },
12 | "include": [
13 | "src/**/*"
14 | ]
15 | }
--------------------------------------------------------------------------------
/docs/.github/workflows/retype-action.yml:
--------------------------------------------------------------------------------
1 | name: Publish Retype powered website to GitHub Pages
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | publish:
10 | name: Publish to retype branch
11 |
12 | runs-on: ubuntu-latest
13 |
14 | permissions:
15 | contents: write
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 |
20 | - uses: retypeapp/action-build@latest
21 |
22 | - uses: retypeapp/action-github-pages@latest
23 | with:
24 | update-branch: true
25 |
--------------------------------------------------------------------------------
/docs/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/docs/256.png
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | docs.differential.dev
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | dev:
2 | retype start
--------------------------------------------------------------------------------
/docs/_includes/head.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/advanced/calling-functions/arguments-and-return-values.md:
--------------------------------------------------------------------------------
1 | # Argument and return values
2 |
3 | One benefit of using Differential is that is abstracts away many of the complexities of network and data serialization. However, as with any abstraction - it is optimised for the most common use-cases and leaks some of the underlying complexity in the form of limitations.
4 |
5 | Differential serializes function arguments and return values with the same algorithm, so the limitations are the same for both.
6 |
7 | ## Serializability
8 |
9 | Differential uses Message Pack for serializing function arguments and return values. It serializes the actual values, not the references to the values. Therefore, if your function arguments or return values contain references to objects, they will not be serialized correctly.
10 |
11 | ## Data Type Compatibility
12 |
13 | This is a table of data types and their compatibility with Differential as validated by the test suite:
14 |
15 | | Data Type | Supported |
16 | | ----------- | --------- |
17 | | `undefined` | ✅ |
18 | | `null` | ✅ |
19 | | `boolean` | ✅ |
20 | | `number` | ✅ |
21 | | `string` | ✅ |
22 | | `bigint` | ✅ |
23 | | `object` | ✅ |
24 | | `Array` | ✅ |
25 | | `Buffer` | ✅ |
26 | | `Date` | ✅ |
27 |
28 | ## Advanced Data Types
29 |
30 | It is theoretically possible to pass more complex data types such as `Map`, `Set`, `TypedArray`, `Error`, `RegExp`, `Function`, `Promise`, `Symbol`, `WeakMap`, `WeakSet`, `ArrayBuffer`, `SharedArrayBuffer`, `DataView`, `Int8Array`, `Uint8Array` etc, as long as they are not nested and do not contain references to objects. However, they go through a serialization process that requires extra work to be properly deserialized on the other side.
31 |
32 | For this purpose, they are left out of the initial offering.
33 |
34 | ## Safety
35 |
36 | Differential adds a layer of safety by validating the data types of the function arguments and return values before serializing them. If the data types are not supported, Differential will throw an error `DifferentialError.INVALID_DATA_TYPE`. The peformance overhead of this validation is negligible. However, you can disable this behaviour by setting the `validate` option to `false`.
37 |
--------------------------------------------------------------------------------
/docs/advanced/handling-failures/compute-recovery.md:
--------------------------------------------------------------------------------
1 | ---
2 | order: 1980
3 | ---
4 |
5 | # Recovering from machine failures
6 |
7 | In a cloud environment, machines can fail at any time. Differential transparently handles machine failures by periodically sending heartbeats to the control-plane, quickly catching and retrying failed operations on a healthy worker. This means that you don't have to worry about your service being unavailable due to a machine failure.
8 |
9 | If a machine fails to send any heartbeats within an interval (default 90 seconds):
10 |
11 | 1. It is marked as unhealthy, and Differential will not send any new requests to it.
12 | 2. The functions in progress are marked as failed, and Differential will retry them on a healthy worker.
13 |
14 | If the machine comes back online, Differential will mark it as healthy, and start sending new requests to it. However, it will disregard any results from the machine for the functions that were marked as failed.
15 |
16 | ```mermaid
17 | sequenceDiagram
18 | participant ControlPlane as Control Plane
19 | participant UnhealthyMachine as Unhealthy Machine
20 | participant HealthyMachine as Healthy Machine
21 |
22 | Note over UnhealthyMachine,ControlPlane: Periodic Heartbeat Check
23 | UnhealthyMachine->>+ControlPlane: Send Heartbeat (Fail)
24 | ControlPlane->>-UnhealthyMachine: Mark as Unhealthy
25 | Note over UnhealthyMachine: No new requests received
26 |
27 | ControlPlane->>+HealthyMachine: Redirect New Requests
28 | Note over HealthyMachine: Continues receiving tasks
29 |
30 | UnhealthyMachine->>+ControlPlane: Attempt to Reconnect
31 | alt Reconnection Successful
32 | ControlPlane->>UnhealthyMachine: Mark as Healthy
33 | Note over UnhealthyMachine: Can now receive new requests
34 | else Reconnection Failed
35 | ControlPlane->>UnhealthyMachine: Keep as Unhealthy
36 | Note over UnhealthyMachine: No new requests
37 | end
38 |
39 | Note over UnhealthyMachine, HealthyMachine: Handling Failed Tasks
40 | ControlPlane->>HealthyMachine: Retry Failed Tasks from Unhealthy Machine
41 | Note over HealthyMachine: Executes retried tasks
42 | ```
43 |
44 | However, it's possible that the particular workload that you're executing on the machine is what makes it crash. To account for this, there's a retry limit for any function call that results in a machine stall (default 1 time). If the function fails more than the retry limit, Differential will mark the function as permanently failed.
45 |
--------------------------------------------------------------------------------
/docs/advanced/handling-failures/how-functions-fail.md:
--------------------------------------------------------------------------------
1 | ---
2 | order: 2000
3 | ---
4 |
5 | # How functions fail
6 |
7 | Functions in Differential can "fail" in 2 main ways.
8 |
9 | ## 1. Promise rejection
10 |
11 | A function can fail via a promise rejection due to an explicity `throw` statement in the function code or due to an unhandled exception.
12 |
13 | Differential does not automatically retry failed function calls - i.e., if a function call fails, it fails. It is up to the client to decide if it wants to retry the function call. This is the scenarios denoted by 1 and 2 above. The exception to this is [predictive retries](../guides/predictive-retries.md), which is an opt-in feature to retry function calls based on the error metadata.
14 |
15 | ## 2. Stalling
16 |
17 | A function can stall if it does not complete within the alotted time. This usually happens if:
18 |
19 | 1. Function fails to complete within the alotted `timeoutSeconds` time.
20 | 2. Worker stalls due to a machine crash or network problems. (See [Recovering from machine failures](./compute-recovery.md) for more information)
21 |
22 | Differential does automatically retry function calls that stall. This can be customized by setting the `retryCountOnStall` key in the call configuration object.
23 |
--------------------------------------------------------------------------------
/docs/advanced/how-things-work/architecture.md:
--------------------------------------------------------------------------------
1 | # Architecture
2 |
3 | !!!
4 | Differential is currently in technical preview. We've seen promising early results, but our architecture is still evolving. We welcome your thoughts on our design choices. Join the discussion on our [GitHub](https://github.com/differentialhq/differential).
5 | !!!
6 |
7 | Differential’s architecture is intentionally straightforward. We adhere to the principle that simplicity leads to easier scalability.
8 |
9 | 
10 |
11 | 1. A stateful control-plane is backed by postgres and served via a REST API.
12 | 2. Services in the user land uses the Differential SDK (`new Differential()`) to register services and ask/poll for incoming function calls.
13 | 3. Clients in the user land uses the Differential SDK (`new Differential()`) to queue function calls and poll for completion.
14 | 4. All "work" is logically grouped into clusters (`$ differential clusters create`).
15 | 5. Console and CLI uses the REST API to manage clusters.
16 |
--------------------------------------------------------------------------------
/docs/advanced/how-things-work/concepts.md:
--------------------------------------------------------------------------------
1 | ---
2 | order: 1400
3 | ---
4 |
5 | # Concepts
6 |
7 | ## Control Plane
8 |
9 | A central book-keeping stateful service that keeps track of all the services that are running, and their health. It acts as a service registry, and a service mesh. Backed by a database, and deployed with multiple replicas. The managed control plane that Differential runs can be accessed at https://console.differential.dev.
10 |
11 | ## Cluster
12 |
13 | A collection of services and workers. All services and workers connect to the control plane via a cluster.
14 |
15 | ```bash
16 | $ differential clusters create
17 | Cluster created successfully
18 | {
19 | id: 'cluster-aged-paper-cb2142f716',
20 | apiSecret: 'sk_my_super_secret'
21 | }
22 | ```
23 |
24 | ## Service
25 |
26 | A collection of functions that can be called by a consumer using the differential client. Defined using the SDK.
27 |
28 | ```typescript
29 | const d = new Differential("sk_my_super_secret");
30 |
31 | export const myService = d.service({
32 | name: "myService",
33 | functions: async function echo(value: string) {
34 | return value;
35 | },
36 | });
37 | ```
38 |
39 | ## Machine
40 |
41 | A computer that runs one or more services. For a machine to identify itself with the control plane, at least one service must be started.
42 |
43 | ```typescript
44 | await myService.start();
45 | ```
46 |
47 | ## Client
48 |
49 | A type-safe entrypoint to your services.
50 |
51 | ```typescript
52 | const d = new Differential("sk_my_super_secret");
53 |
54 | const client = d.client("myService");
55 |
56 | await d.echo("hello"); // hello
57 | ```
58 |
--------------------------------------------------------------------------------
/docs/advanced/how-things-work/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/docs/advanced/how-things-work/image.png
--------------------------------------------------------------------------------
/docs/advanced/how-things-work/limitations.md:
--------------------------------------------------------------------------------
1 | # Limitations
2 |
3 | ## Programming language
4 |
5 | Differential only supports TypeScript until it gets to a stable release. We do plan to support other languages in the future, however we are intensely focused on creating the right abstractions for a single language first.
6 |
7 | There's nothing preventing your from using Differential with JavaScript, however you will lose out on the type safety that Differential provides for your clients.
8 |
--------------------------------------------------------------------------------
/docs/advanced/how-things-work/service-discovery.md:
--------------------------------------------------------------------------------
1 | # Service Discovery
2 |
3 | Status: **General Availability**
4 |
5 | Differential comes with a built-in service registry, so you can call your services by name, without having to worry about IP addresses, ports, or where it's deployed. Your services phone-home to Differential control-plane.
6 |
7 | Your services (whether running in your own compute or Differential cloud) always poll the control-plane to:
8 |
9 | 1. Advertise their presence
10 | 2. Advertise their health
11 | 3. Ask for work
12 |
13 | This allows you to deploy your services anywhere, even across multiple cloud providers and regions, and still have them communicate with each other without any networking configuration.
14 |
15 | It also cuts down the need to secure your services with service to service authentication, as the machines are not directly communicating with each other, or accepting incoming connections. This improves the security posture of your services.
16 |
--------------------------------------------------------------------------------
/docs/advanced/index.yml:
--------------------------------------------------------------------------------
1 | icon: plus
2 | expanded: false
3 | order: 980
4 |
--------------------------------------------------------------------------------
/docs/assets/image-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/docs/assets/image-1.png
--------------------------------------------------------------------------------
/docs/deployments/index.yml:
--------------------------------------------------------------------------------
1 | icon: cloud
2 | expanded: false
3 | order: 980
4 |
--------------------------------------------------------------------------------
/docs/getting-started/index.yml:
--------------------------------------------------------------------------------
1 | order: 1000
2 | icon: rocket
3 | expanded: true
4 |
--------------------------------------------------------------------------------
/docs/guides/deduplicating.md:
--------------------------------------------------------------------------------
1 | # How to deduplicate function calls
2 |
3 | Consider a scenario where two or more function calls are issued for the same operation. Your code may be racing to complete the operation multiple times, or it may be the network which is repeatedly timing out and causing the same operation to be retried. In such cases, you may want to deduplicate the function calls to ensure that the operation is only executed once.
4 |
5 | ## Execution ID
6 |
7 | Execution ID is a cluster-wide unique identifier that is generated and set for each function call.
8 |
9 | Usually, the control-plane takes care of generating and setting the execution ID. However, you can also set the execution ID manually to deduplicate function calls.
10 |
11 | ## What happens if you call a function with the same Execution ID?
12 |
13 | When two or more function calls happen with the same Execution ID, the subsequent function calls will get the same result as the original function call.
14 |
15 | If the first function call is still in progress, the subsequent function calls will wait for the original function call to complete and return the same result.
16 |
17 | If the first function call has already completed, the subsequent function calls will return the same result as the original function call.
18 |
19 | ## How to deduplicate function calls
20 |
21 | De-duplicating the function call can be done by setting the Execution ID for the function call. Here is an example of how you can set the Execution ID for a function call:
22 |
23 | ```typescript
24 | const result = await client.myFunction("arg1", "arg2", {
25 | $d: {
26 | executionId: "myExecutionId",
27 | },
28 | });
29 |
30 | // same function, again
31 | const result2 = await client.myFunction("arg1", "arg2", {
32 | $d: {
33 | executionId: "myExecutionId",
34 | },
35 | });
36 |
37 | // another function in a different service
38 | const result3 = await otherClient.myFunction("arg1", "arg2", {
39 | $d: {
40 | executionId: "myExecutionId",
41 | },
42 | });
43 |
44 | assert.deepEqual(result, result3); // result and result3 will be the same because Exeuction ID needs to be unique cluster wide
45 | ```
46 |
--------------------------------------------------------------------------------
/docs/guides/end-to-end-encryption.md:
--------------------------------------------------------------------------------
1 | # How to end-to-end encrypt function arguments and return values
2 |
3 | You might wish to encrypt all function arguments and return values, so that the control plane cannot see them. This is possible with Differential, but it requires you to configure your own encryption keys.
4 |
5 | These encryption keys are used to encrypt and decrypt the function arguments and return values. The control plane does not have access to these keys.
6 |
7 | The Typescript example below shows how to configure your own encryption keys.
8 |
9 | ```typescript
10 | const d = new Differential("API_SECRET", {
11 | encryptionKeys: [
12 | Buffer.from("abcdefghijklmnopqrstuvwxzy123456"), // 32 bytes
13 | ],
14 | });
15 | ```
16 |
17 | It accepts an array of encryption keys. This is useful if you want to rotate your encryption keys. Differential will try to decrypt the function arguments and return values with each encryption key until it finds one that works.
18 |
19 | Since this essentially makes function arguments opaque to the control plane, it is important to note that Differential will not be able to provide any of its usual features for these encrypted function arguments, such as predictive retries or predictive alerting.
20 |
21 | Caching is still supported, as it is based on the function arguments and return values as supplied by the client.
22 |
--------------------------------------------------------------------------------
/docs/guides/idempotency.md:
--------------------------------------------------------------------------------
1 | # How to ensure idempotency in your functions
2 |
3 | Idempotency is a property of an operation that means the operation can be applied multiple times without changing the result beyond the initial application. In other words, making the same request multiple times has the same effect as making the request once.
4 |
5 | In other words, you'd have to ensure:
6 |
7 | 1. The intended operation is applied at least once.
8 | 2. The intended operation is not applied multiple times.
9 |
10 | ## 1. Making sure the intended operation is applied at least once
11 |
12 | Checking whether the operation is applied successfully is a concern of the developer - in the context of Differential. You can use the [retry policy](https://docs.differential.dev/advanced/calling-functions/customizing-function-calls/#retries) to make sure your functions can recover from machines stalling.
13 |
14 | ```typescript
15 | // service.ts
16 | async function chargeOrder(orderId: string, paymentMethod: PaymantMethod) {
17 | // Check if the order has already been charged
18 | const charge = await getChargeForOrder(orderId);
19 |
20 | if (charge) {
21 | return charge;
22 | }
23 |
24 | // Charge the order
25 | const charge = await chargeOrderWithPaymentMethod(orderId, paymentMethod);
26 |
27 | return charge;
28 | }
29 |
30 | export const orderService = d.service({
31 | name: "order",
32 | functions: {
33 | chargeOrder,
34 | },
35 | });
36 |
37 | // client.ts
38 | function chargeOrder() {
39 | await orderService.chargeOrder(orderId, paymentMethod, {
40 | $d: {
41 | retryCountOnStall: 1, // function will be tried on stalling
42 | },
43 | });
44 | }
45 | ```
46 |
47 | ## Preventing duplicate calls
48 |
49 | At the same time, it's important to make sure that additional clients do not apply the same operation.
50 |
51 | For this purpose, you can use [`$d.executionId`](https://docs.differential.dev/advanced/calling-functions/customizing-function-calls/#execution-id) in the call config that will prevent additional callers from triggering multiple calls.
52 |
53 | Therefore, the function will look like:
54 |
55 | ```typescript
56 | function chargeOrder() {
57 | await orderService.chargeOrder(orderId, paymentMethod, {
58 | $d: {
59 | retryCountOnStall: 1, // function will be tried on stalling
60 | executionId: orderId, // additional clients will not execute `chargeOrder` again for the same key
61 | },
62 | });
63 | }
64 | ```
65 |
--------------------------------------------------------------------------------
/docs/guides/index.yml:
--------------------------------------------------------------------------------
1 | order: 990
2 | icon: book
3 | expanded: false
4 |
--------------------------------------------------------------------------------
/docs/guides/predictive-alerting.md:
--------------------------------------------------------------------------------
1 | # Predictive Alerting
2 |
3 | Status: **In Development**
4 |
5 | Differential can predict if a function is failing due to an unrecoverable error that requires a code change, and alert you with reproduction steps.
6 |
7 | The control-plane has all the required context on a particular failure, when it happens. It knows the function, some metadata about the source, payload, and the error message. It can use this information to predict if the error is unrecoverable or not.
8 |
9 | If it's predicted to be unrecoverable, Differential will alert you prompting to make the necessary code change. This is especially useful for errors that are hard to reproduce, and require a code change to fix.
10 |
11 | For example, let's consider this that occurs due to a data inconsistency:
12 |
13 | ```typescript
14 | // service.ts
15 | const getCustomerName = async (customerId: string) => {
16 | const customer = await getCustomerFromDatabase(customerId);
17 | return customer.name;
18 | };
19 |
20 | const customerService = d.service({
21 | name: "customer",
22 | functions: {
23 | getCustomerName,
24 | },
25 | });
26 |
27 | // consumer.ts
28 | const client = d.client("customer");
29 |
30 | await client.getCustomerName("invalid_id"); // TypeError: Cannot read property 'name' of undefined
31 | ```
32 |
33 | In this case, Differential can predict that the error is unrecoverable, and prompt you to make a code change. It will provide you with the exact payload that caused the error, and the reproduction steps to reproduce the error locally.
34 |
--------------------------------------------------------------------------------
/docs/guides/recovering-from-infrastructure-stalls.md:
--------------------------------------------------------------------------------
1 | # Retrying function calls on infrastructure failures
2 |
3 | Sometimes the functions can stall due to a machine crashing, network issues, or other infrastructure failures. In these cases, you can instruct Differential to retry the function call without writing any custom error recovery logic.
4 |
5 | ## Retry Configuration
6 |
7 | You can configure the number of times a function call should be retried in case of a stall. By default, Differential retries the function call once. You can customize this behavior by setting the `retryCountOnStall` key in the configuration object.
8 |
9 | Imagine you have a function that takes a long time to execute:
10 |
11 | ```typescript
12 | async function myFunction(arg1: string, arg2: string) {
13 | // wait for 1 minute
14 | await new Promise(() => {}, 5 * 60 * 1000);
15 |
16 | return "done";
17 | }
18 | ```
19 |
20 | You can configure the number of times the function should be retried in case of a stall:
21 |
22 | ```typescript
23 | const result = await client.myFunction("arg1", "arg2", {
24 | $d: {
25 | retryCountOnStall: 3, // function might be retried 3 times on stalling
26 | timeoutSeconds: 10, // function will timeout after 10 seconds
27 | },
28 | });
29 |
30 | const result = await client.myFunction("arg1", "arg2", {
31 | $d: {
32 | retryCountOnStall: 0, // function will not be retried on stalling
33 | timeoutSeconds: 10, // function will timeout after 10 seconds
34 | },
35 | });
36 | ```
37 |
38 | ## Default Behavior
39 |
40 | The default behavior is to retry the function call once, after the function times out. The default timeout is 5 minutes.
41 |
42 | For example, the following function will be retried once after 5 minutes.
43 |
44 | ## Retries vs. Failures
45 |
46 | Note that this retry mechanism is different from the retries that are done when a function call is rejected. When a function call is explicitly rejected becasue of a `throw`, `reject` or an unhandled exception, it is up to the client to retry the function call.
47 |
--------------------------------------------------------------------------------
/docs/guides/shared-client-libraries.md:
--------------------------------------------------------------------------------
1 | # Building Shared Client Libraries for Services
2 |
3 | Status: **Private Beta**
4 |
5 | Differential supports publishing a client library for your cluster which can be included in a separate project using `npm`. Client libraries are distributed privately via a `node` package registry managed by Differential.
6 |
7 | The [Differential CLI](https://github.com/differentialhq/differential/tree/main/cli) is used to manage client libraries, you can publish a new client library with the `client publish` command.
8 |
9 | > Differential uses [semantic versioning](https://semver.org), as part of publishing the client library you will be asked to describe the change increment (`patch`, `minor`, `majod`).
10 |
11 | ```sh
12 | differential auth login
13 | differential client publish
14 | ```
15 |
16 | Once published, the client can be installed in other projects as you would any other `node` package.
17 |
18 | > The `differential auth login` CLI command configures the host's `~/.npmrc` configuration to authenticate with the Differential package registry. If you need to install the package from another host, run `npm auth login`.
19 |
20 | ```sh
21 | npm i @differential.dev/
22 | ```
23 |
24 | This will provide a type safe client which can be used to call any service in the cluster.
25 |
26 | ```typescript
27 | import { d } from "../d";
28 | import type { helloService } from "@differential.dev/";
29 |
30 | const client = d.client("hello");
31 | ....
32 | ```
33 |
34 | Shared client libraries are currently in private beta and will be available soon. To gain early access, please sign up for the waitlist [here](https://forms.fillout.com/t/9M1VhL8Wxyus).
35 |
--------------------------------------------------------------------------------
/docs/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/docs/image.png
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | order: 1500
3 | icon: home
4 | expanded: true
5 | ---
6 |
7 | # Differential
8 |
9 | > Build, Deploy and Refactor fault-tolerant back-end apps without the ceremony.
10 |
11 | !!!
12 | Differential is in the technical preview stage, and is open-source. We are working hard to make our offering generally available. Sign up for the waitlist [here](https://forms.fillout.com/t/9M1VhL8Wxyus).
13 | !!!
14 |
15 | Differential is an open-source application code aware service mesh (control-plane) and a set of adapters (client libraries) which connects your services together with first-class support for Typescript.
16 |
17 | Differential is a framework that builds on the concepts developers are already familiar with.
18 |
19 | Services are collections of plain old javascript functions which can be deployed in almost any compute. Services ship their own type-safe Typescript clients.
20 |
21 | The control plane takes care of routing data between the functions, and recovering from transient failures, transparently.
22 |
23 | ## Open Source and Self-Hostable
24 |
25 | Differential is released under the Apache 2.0 license, and can be self-hosted. This means you can run your own control-plane, and have full control over your data and infrastructure. Optionally, you can use Differential Cloud, which is a fully managed offering that we are working hard to make generally available. Sign up for the waitlist [here](https://forms.fillout.com/t/9M1VhL8Wxyus).
26 |
27 | ## Quick Start
28 |
29 | Follow the [Quick Start](./getting-started/quick-start.md) guide to get up and running with Differential in under 2 minutes.
30 |
31 | ## See Also:
32 |
33 | - [Get up and running with Differential in under 2 minutes](https://docs.differential.dev/getting-started/quick-start/)
34 | - [Thinking in Differential](https://docs.differential.dev/getting-started/thinking/)
35 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@differentialhq/docs",
3 | "version": "0.0.36",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "exit 0",
8 | "build": "exit 0"
9 | },
10 | "dependencies": {
11 | "@differentialhq/core": "3.1.4"
12 | },
13 | "author": "",
14 | "license": "ISC",
15 | "private": true
16 | }
--------------------------------------------------------------------------------
/docs/retype.yml:
--------------------------------------------------------------------------------
1 | input: .
2 | output: .retype
3 | url: https://docs.differential.dev
4 | branding:
5 | title: Differential Docs
6 | logo: /256.png
7 | links:
8 | - text: Home
9 | link: https://www.differential.dev
10 | - text: Differential Cloud
11 | link: https://forms.fillout.com/t/9M1VhL8Wxyus
12 | footer:
13 | copyright: "© Copyright {{ year }}. All rights reserved."
14 |
--------------------------------------------------------------------------------
/infrastructure/README.md:
--------------------------------------------------------------------------------
1 | # Differential Infrastructure
2 |
3 | This directory provides infrastructure as code definitions for additional infrastructure resources used by Differential.
4 |
5 | ## AWS CloudFormation
6 |
7 | The `./cfn.yaml` file describes an [AWS CloudFormation](https://aws.amazon.com/cloudformation/) stack providing resources for:
8 |
9 | - Client library registry
10 | - Lambda deployments
11 |
12 | ### Creating stack initially
13 |
14 | The stack can be provisioned by running the following command in your shell:
15 |
16 | ```sh
17 | aws cloudformation create-stack \
18 | --stack-name $STACK_NAME \
19 | --template-body file://cfn.yaml \
20 | --parameters \
21 | ParameterKey=AssetUploadBucketName,ParameterValue=${ASSET_BUCKET_NAME} \
22 | ParameterKey=CfnTemplateBucketName,ParameterValue=${CFN_BUCKET_NAME} \
23 | ParameterKey=CfnSNSWebhook,ParameterValue=${CFN_WEBHOOK} \
24 | --capabilities CAPABILITY_NAMED_IAM CAPABILITY_IAM
25 | ```
26 |
27 | ### Updating the stack
28 |
29 | Over time, new resources may be requried, changes can be applied to the stack by running the following command in your shell:
30 |
31 | ```sh
32 | aws cloudformation update-stack \
33 | --stack-name $STACK_NAME \
34 | --template-body file://cfn.yaml \
35 | --parameters ParameterKey=AssetUploadBucketName,ParameterValue=${ASSET_BUCKET_NAME} \
36 | ParameterKey=CfnTemplateBucketName,ParameterValue=${CFN_BUCKET_NAME} \
37 | ParameterKey=CloudFormationWebhook,ParameterValue=${CFN_WEBHOOK} \
38 | --capabilities CAPABILITY_NAMED_IAM CAPABILITY_IAM
39 | ```
40 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json",
3 | "version": "independent"
4 | }
5 |
--------------------------------------------------------------------------------
/load-tester/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # Load Tester
6 |
7 | This is a simple load testing apparatus for testing api.differential.dev.
8 |
9 | ## Usage
10 |
11 | Check the [basic example](./src/commands/basic.js) for a simple example of how to write new tests.
12 |
13 | ## Running
14 |
15 | ```sh
16 | npm run load-test
17 | ```
18 |
19 | The results will be appended to results.jsonl file in the root of the directory.
--------------------------------------------------------------------------------
/load-tester/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@differentialhq/load-tester",
3 | "version": "0.0.1",
4 | "description": "Load testing tool for api.differential.dev",
5 | "bin": "setup.sh",
6 | "type": "module",
7 | "scripts": {
8 | "test": "exit 0",
9 | "load-test": "sh run.sh",
10 | "service": "tsx src/services/$npm_config_name.ts --start",
11 | "command": "tsx src/commands/$npm_config_name.ts --start"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+ssh://git@github.com/differentialhq/differential.git"
16 | },
17 | "author": "differential.dev",
18 | "license": "MIT",
19 | "bugs": {
20 | "url": "https://github.com/differentialhq/differential/issues"
21 | },
22 | "homepage": "https://github.com/differentialhq/differential#readme",
23 | "dependencies": {
24 | "@differentialhq/core": "^3.12.2",
25 | "tsx": "^4.6.2",
26 | "typescript": "^5.3.3"
27 | },
28 | "devDependencies": {
29 | "@types/node": "^20.10.4"
30 | }
31 | }
--------------------------------------------------------------------------------
/load-tester/run.sh:
--------------------------------------------------------------------------------
1 | npm run service --name=executor&
2 | PID=$!
3 |
4 | # Basic
5 | npm run command --name=basic
6 | npm run command --name=parallel-100
7 | npm run command --name=parallel-1000
8 | # fails. needs connection reuse.
9 | # npm run command --name=parallel-10000
10 |
11 | kill $PID
12 |
--------------------------------------------------------------------------------
/load-tester/scripts/setup.sh:
--------------------------------------------------------------------------------
1 | echo "Setting up the project..."
2 |
3 | echo "Installing dependencies..."
4 | npm install
5 |
6 | echo "Obtaining API secret..."
7 | API_SECRET=$(curl https://api.differential.dev/demo/token)
8 |
9 | # replace the API_SECRET with the string REPLACE_ME in src/d.ts file.
10 | sed -i '' "s/REPLACE_ME/$API_SECRET/g" src/d.ts
11 |
12 | echo "Setup complete! 🎉"
13 |
14 | echo ""
15 | echo "To play with the demo, run the following commands in separate terminals:"
16 | echo " 👉 Running the service:"
17 | echo " npm run service --name=hello"
18 | echo " 👉 Running the consumer:"
19 | echo " npm run command --name=greet"
20 | echo ""
--------------------------------------------------------------------------------
/load-tester/src/commands/basic.ts:
--------------------------------------------------------------------------------
1 | import { assert } from "console";
2 | import { d } from "../d";
3 | import type { executorService } from "../services/executor";
4 | import { t } from "../t";
5 |
6 | const executorClient = d.client("executor");
7 |
8 | t(async function basic() {
9 | const input = Math.random().toString();
10 |
11 | const expectations = {
12 | object: { type: "object", value: input },
13 | string: input,
14 | array: [input],
15 | number: 1,
16 | boolean: true,
17 | null: null,
18 | undefined: undefined,
19 | };
20 |
21 | const result = await Promise.all(
22 | Object.keys(expectations).map((outputType) =>
23 | executorClient.exec({
24 | wait: 0,
25 | error: false,
26 | input,
27 | outputType: outputType as any,
28 | kill: false,
29 | })
30 | )
31 | );
32 |
33 | assert(
34 | JSON.stringify(result) === JSON.stringify(Object.values(expectations))
35 | );
36 | });
37 |
--------------------------------------------------------------------------------
/load-tester/src/commands/parallel-100.ts:
--------------------------------------------------------------------------------
1 | import { d } from "../d";
2 | import type { executorService } from "../services/executor";
3 | import { t } from "../t";
4 | import { parallel } from "./parallel";
5 |
6 | export const executorClient = d.client("executor");
7 |
8 | t(() => parallel(100));
9 |
--------------------------------------------------------------------------------
/load-tester/src/commands/parallel-1000.ts:
--------------------------------------------------------------------------------
1 | import { t } from "../t";
2 | import { parallel } from "./parallel";
3 |
4 | t(() => parallel(1000));
5 |
--------------------------------------------------------------------------------
/load-tester/src/commands/parallel-10000.ts:
--------------------------------------------------------------------------------
1 | import { t } from "../t";
2 | import { parallel } from "./parallel";
3 |
4 | t(() => parallel(10000));
5 |
--------------------------------------------------------------------------------
/load-tester/src/commands/parallel.ts:
--------------------------------------------------------------------------------
1 | import { assert } from "console";
2 | import { executorClient } from "./parallel-100";
3 |
4 | export async function parallel(n: number) {
5 | const inputs = new Array(n).fill(null).map(() => Math.random().toString());
6 |
7 | const promises = inputs.map((input) => {
8 | return executorClient.exec({
9 | wait: 0,
10 | error: false,
11 | input,
12 | outputType: "string",
13 | kill: false,
14 | });
15 | });
16 |
17 | const outputs = await Promise.all(promises);
18 |
19 | outputs.forEach((output, i) => {
20 | assert(output === inputs[i]);
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/load-tester/src/d.ts:
--------------------------------------------------------------------------------
1 | import { Differential } from "@differentialhq/core";
2 |
3 | if (!process.env.LOAD_TESTER_API_SECRET) {
4 | throw new Error("LOAD_TESTER_API_SECRET not set");
5 | }
6 |
7 | export const d = new Differential(process.env.LOAD_TESTER_API_SECRET!);
8 |
--------------------------------------------------------------------------------
/load-tester/src/services/executor.ts:
--------------------------------------------------------------------------------
1 | import { d } from "../d";
2 | import process from "process";
3 |
4 | async function exec(execArgs: {
5 | wait: number;
6 | error: boolean;
7 | input: string;
8 | outputType:
9 | | "object"
10 | | "string"
11 | | "array"
12 | | "number"
13 | | "boolean"
14 | | "null"
15 | | "undefined";
16 | kill: boolean;
17 | }) {
18 | if (execArgs.wait) {
19 | await new Promise((resolve) => setTimeout(resolve, execArgs.wait));
20 | }
21 |
22 | if (execArgs.error) {
23 | throw new Error("error");
24 | }
25 |
26 | if (execArgs.kill) {
27 | process.kill(process.pid, "SIGTERM");
28 | }
29 |
30 | switch (execArgs.outputType) {
31 | case "object":
32 | return { type: "object", value: execArgs.input };
33 | case "string":
34 | return execArgs.input;
35 | case "array":
36 | return [execArgs.input];
37 | case "number":
38 | return 1;
39 | case "boolean":
40 | return true;
41 | case "null":
42 | return null;
43 | case "undefined":
44 | return undefined;
45 | }
46 | }
47 |
48 | export const executorService = d.service({
49 | name: "executor",
50 | functions: {
51 | exec,
52 | },
53 | });
54 |
55 | executorService.start().then(() => {
56 | console.log("executor started");
57 | });
58 |
--------------------------------------------------------------------------------
/load-tester/src/t.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 |
4 | export const t = async (fn: () => Promise) => {
5 | const start = Date.now();
6 |
7 | await fn();
8 |
9 | const end = Date.now();
10 |
11 | const duration = end - start;
12 | const name = fn.name;
13 | const date = new Date().toISOString();
14 |
15 | const data = { name, duration, date };
16 |
17 | console.log(`Test ${name} took ${duration}ms`);
18 |
19 | fs.appendFileSync("results.jsonl", JSON.stringify(data) + "\n");
20 | };
21 |
--------------------------------------------------------------------------------
/load-tester/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2016",
4 | "module": "commonjs",
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "strict": true,
8 | "skipLibCheck": true,
9 | "types": [
10 | "node"
11 | ]
12 | }
13 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "root",
3 | "private": true,
4 | "workspaces": [
5 | "sdk",
6 | "control-plane",
7 | "docs",
8 | "admin",
9 | "cli"
10 | ],
11 | "devDependencies": {
12 | "conventional-changelog-cli": "^4.1.0",
13 | "husky": "^8.0.3",
14 | "lerna": "^8.1.2",
15 | "lerna-changelog": "^2.2.0",
16 | "lint-staged": "^15.2.0",
17 | "prettier": "^3.2.2"
18 | },
19 | "repository": "https://github.com/differentialhq/differential",
20 | "lint-staged": {
21 | "*.{js,css,md,ts,tsx}": "prettier --write"
22 | },
23 | "scripts": {
24 | "prepare": "npx husky install",
25 | "sync-contracts": "cp control-plane/src/modules/contract.ts ts-core/src/contract.ts && cp control-plane/src/modules/contract.ts ts-core/src/contract.ts && cp control-plane/src/modules/contract.ts admin/client/contract.ts && cp control-plane/src/modules/contract.ts cli/src/client/contract.ts"
26 | }
27 | }
--------------------------------------------------------------------------------
/sdk/Makefile:
--------------------------------------------------------------------------------
1 | all: fetch
2 |
3 | update-contract:
4 | mkdir -p src
5 | curl https://api.differential.dev/contract | jq -r '.contract' > src/contract.ts
6 |
7 | clean:
8 | rm -f src/contract.ts
9 |
10 | .PHONY: all fetch clean
11 |
--------------------------------------------------------------------------------
/sdk/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/assets/logo.png
--------------------------------------------------------------------------------
/sdk/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/sdk/docs/.nojekyll:
--------------------------------------------------------------------------------
1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.
--------------------------------------------------------------------------------
/sdk/docs/modules.md:
--------------------------------------------------------------------------------
1 | # @differentialhq/sdk
2 |
--------------------------------------------------------------------------------
/sdk/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@differentialhq/sdk",
3 | "version": "0.0.2",
4 | "description": "Javascript SDK for differential.dev",
5 | "main": "bin/index.js",
6 | "scripts": {
7 | "test": "exit 0",
8 | "test:bak": "jest ./src --runInBand --forceExit --detectOpenHandles",
9 | "test:dev": "jest ./src --watch",
10 | "build": "tsc",
11 | "clean": "rm -rf ./bin",
12 | "docs": "typedoc --plugin typedoc-plugin-markdown --out docs --excludePrivate --hideBreadcrumbs --allReflectionsHaveOwnDocument"
13 | },
14 | "author": "",
15 | "license": "ISC",
16 | "dependencies": {
17 | "@ts-rest/core": "^3.28.0",
18 | "debug": "^4.3.4",
19 | "google-auth-library": "^8.9.0",
20 | "google-spreadsheet": "^4.1.1",
21 | "msgpackr": "^1.9.7",
22 | "node-os-utils": "^1.3.7",
23 | "zod": "^3.23.5",
24 | "zod-to-json-schema": "^3.23.0"
25 | },
26 | "devDependencies": {
27 | "@babel/preset-env": "^7.22.10",
28 | "@babel/preset-typescript": "^7.22.11",
29 | "@types/debug": "^4.1.8",
30 | "@types/jest": "^29.5.4",
31 | "@types/node-os-utils": "^1.3.4",
32 | "dotenv": "^16.3.1",
33 | "jest": "^29.6.4",
34 | "typedoc": "^0.25.4",
35 | "typedoc-plugin-markdown": "^3.17.1",
36 | "typescript": "^5.2.2"
37 | },
38 | "jest": {
39 | "testTimeout": 20000
40 | }
41 | }
--------------------------------------------------------------------------------
/sdk/src/Differential.test.ts:
--------------------------------------------------------------------------------
1 | import { Differential } from "./Differential";
2 |
3 | describe("Differential", () => {
4 | const env = process.env;
5 | beforeEach(() => {
6 | delete process.env.DIFFERENTIAL_API_SECRET;
7 | });
8 |
9 | afterEach(() => {
10 | process.env = { ...env };
11 | });
12 | it("should initialize without optional args", () => {
13 | expect(() => new Differential("test")).not.toThrow();
14 | });
15 |
16 | it("should throw if no API secret is provided", () => {
17 | expect(() => new Differential()).toThrow();
18 | });
19 |
20 | it("should initialize with API secret in environment", () => {
21 | process.env.DIFFERENTIAL_API_SECRET = "environment_secret";
22 | expect(() => new Differential()).not.toThrow();
23 | const d = new Differential();
24 | expect(d.secretPartial).toBe("envi...");
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/sdk/src/call-config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Configures the function call with extra options. All options need to be configured under the $d key.
3 | *
4 | * @example
5 | * ```ts
6 | * await client.getUsers({
7 | * $d: {
8 | * cache: {
9 | * key: "users",
10 | * ttlSeconds: 60,
11 | * },
12 | * });
13 | * ```
14 | */
15 | export type CallConfig = {
16 | cache?: {
17 | key: string;
18 | ttlSeconds: number;
19 | };
20 | retryCountOnStall?: number;
21 | predictiveRetriesOnRejection?: boolean;
22 | timeoutSeconds?: number;
23 | executionId?: string;
24 | // TODO: time travel
25 | };
26 |
27 | export type $d = {
28 | $d: CallConfig;
29 | };
30 |
31 | type AddParameters = (
32 | ...args: [Arg, Parameter]
33 | ) => ReturnType;
34 |
35 | export type CallConfiguredFunction any> =
36 | AddParameters<
37 | Parameters[0],
38 | ReturnType,
39 | $d | undefined
40 | >;
41 |
42 | export type CallConfiguredBackgroundFunction<
43 | TFunction extends (...args: any) => any,
44 | > = AddParameters>;
45 |
46 | export const extractCallConfig = (
47 | args: any[],
48 | ): {
49 | callConfig?: $d["$d"];
50 | originalArgs: any[];
51 | } => {
52 | const lastArg = args[args.length - 1];
53 |
54 | if (typeof lastArg === "object" && lastArg !== null && "$d" in lastArg) {
55 | return {
56 | callConfig: lastArg.$d,
57 | originalArgs: args.slice(0, args.length - 1),
58 | };
59 | }
60 |
61 | return {
62 | originalArgs: args,
63 | };
64 | };
65 |
--------------------------------------------------------------------------------
/sdk/src/create-client.ts:
--------------------------------------------------------------------------------
1 | import { initClient, tsRestFetchApi } from "@ts-rest/core";
2 | import { contract } from "./contract";
3 |
4 | export const createClient = ({
5 | baseUrl,
6 | machineId,
7 | deploymentId,
8 | clientAbortController,
9 | }: {
10 | baseUrl: string;
11 | machineId: string;
12 | deploymentId?: string;
13 | clientAbortController?: AbortController;
14 | }) =>
15 | initClient(contract, {
16 | baseUrl,
17 | baseHeaders: {
18 | "x-machine-id": machineId,
19 | ...(deploymentId && { "x-deployment-id": deploymentId }),
20 | },
21 | api: clientAbortController
22 | ? (args) => {
23 | return tsRestFetchApi({
24 | ...args,
25 | signal: clientAbortController.signal,
26 | });
27 | }
28 | : undefined,
29 | });
30 |
--------------------------------------------------------------------------------
/sdk/src/errors.ts:
--------------------------------------------------------------------------------
1 | export class DifferentialError extends Error {
2 | static UNAUTHORISED =
3 | "Invalid API Key or API Secret. Make sure you are using the correct API Secret.";
4 |
5 | static UNKNOWN_ENCRYPTION_KEY =
6 | "Encountered an encrypted message with an unknown encryption key. Make sure you are providing all encryption keys to the client.";
7 |
8 | static EXECUTION_DID_NOT_COMPLETE =
9 | "An error occurred while executing the function remotely. Machine may have stalled too many times or the function timed out before reporting a completion status.";
10 |
11 | static TOO_MANY_NETWORK_ERRORS =
12 | "Too many network errors occurred. Make sure the client is connected to the internet.";
13 |
14 | static INVALID_DATA_TYPE =
15 | "Serialization process encountered an invalid data type. The data can not be safely serialized. See: https://docs.differential.dev/advanced/arguments-and-return-values/";
16 |
17 | static TIMEOUT =
18 | "The function timed out before being able to complete. Make sure the function is not stalling.";
19 |
20 | constructor(message: string, meta?: { [key: string]: unknown }) {
21 | super(message);
22 | this.name = "DifferentialError";
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/sdk/src/events.test.ts:
--------------------------------------------------------------------------------
1 | import * as osu from "node-os-utils";
2 | import { Events } from "./events";
3 |
4 | describe("events", () => {
5 | beforeAll(() => {
6 | jest.useFakeTimers();
7 | });
8 |
9 | afterAll(() => {
10 | jest.useRealTimers();
11 | });
12 |
13 | it("should flush events when bufferFlushSize is reached", () => {
14 | const mockFn = jest.fn();
15 | const events = new Events(mockFn, {
16 | bufferFlushSize: 2,
17 | });
18 | expect(mockFn).toHaveBeenCalledTimes(0);
19 | events.push({ type: "functionInvocation", timestamp: new Date() });
20 | expect(mockFn).toHaveBeenCalledTimes(0);
21 | events.push({ type: "functionInvocation", timestamp: new Date() });
22 | expect(mockFn).toHaveBeenCalledTimes(1);
23 | events.push({ type: "functionInvocation", timestamp: new Date() });
24 | expect(mockFn).toHaveBeenCalledTimes(1);
25 | events.push({ type: "functionInvocation", timestamp: new Date() });
26 | expect(mockFn).toHaveBeenCalledTimes(2);
27 | });
28 |
29 | it("should schedule a system probe", () => {
30 | const mockFn = jest.fn();
31 | osu.cpu.usage = mockFn.mockResolvedValue(0.5);
32 |
33 | const events = new Events(mockFn, {
34 | systemProbeInterval: 100,
35 | });
36 |
37 | events.startResourceProbe();
38 | expect(mockFn).toHaveBeenCalledTimes(0);
39 |
40 | jest.advanceTimersByTime(100);
41 | expect(mockFn).toHaveBeenCalledTimes(1);
42 |
43 | jest.advanceTimersByTime(100);
44 | expect(mockFn).toHaveBeenCalledTimes(2);
45 |
46 | events.stopResourceProbe();
47 | jest.advanceTimersByTime(100);
48 | expect(mockFn).toHaveBeenCalledTimes(2);
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/sdk/src/events.ts:
--------------------------------------------------------------------------------
1 | import * as osu from "node-os-utils";
2 |
3 | type EventTypes = "functionInvocation" | "machineResourceProbe";
4 | type Event = {
5 | timestamp: Date;
6 | type: EventTypes;
7 | tags?: Record;
8 | intFields?: Record;
9 | };
10 |
11 | type PublishEvents = (events: Event[]) => Promise;
12 |
13 | export class Events {
14 | private metricsInterval?: NodeJS.Timeout;
15 | private eventBuffer: Event[] = [];
16 | private publish: PublishEvents;
17 | private systemProbeInterval: number;
18 | private bufferFlushSize: number;
19 |
20 | constructor(
21 | publish: PublishEvents,
22 | options?: {
23 | systemProbeInterval?: number;
24 | bufferFlushSize?: number;
25 | },
26 | ) {
27 | this.publish = publish;
28 | this.systemProbeInterval = options?.systemProbeInterval || 5000;
29 | this.bufferFlushSize = options?.bufferFlushSize || 1000;
30 | }
31 |
32 | public startResourceProbe() {
33 | if (this.metricsInterval) {
34 | return;
35 | }
36 |
37 | this.metricsInterval = setInterval(async () => {
38 | const cpuUsage = await osu.cpu.usage();
39 | const memUsage = await osu.mem.info();
40 |
41 | this.eventBuffer.push({
42 | type: "machineResourceProbe",
43 | timestamp: new Date(),
44 | intFields: {
45 | cpuPercentage: cpuUsage,
46 | memPercentage: memUsage.usedMemPercentage,
47 | memUsageByte: process.memoryUsage().rss,
48 | },
49 | });
50 |
51 | this.flush();
52 | }, this.systemProbeInterval);
53 | }
54 |
55 | public stopResourceProbe() {
56 | if (this.metricsInterval) {
57 | clearInterval(this.metricsInterval);
58 | this.metricsInterval = undefined;
59 | }
60 | }
61 |
62 | public push(event: Event) {
63 | this.eventBuffer.push(event);
64 | this.flush();
65 | }
66 |
67 | public flush() {
68 | if (this.eventBuffer.length < this.bufferFlushSize) {
69 | return;
70 | }
71 |
72 | // Do not await for the publish to finish
73 | this.publish(this.eventBuffer);
74 |
75 | this.eventBuffer = [];
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/sdk/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * # @differentialhq/core
3 | *
4 | * ## Installation
5 | *
6 | * ```bash
7 | * npm install @differentialhq/core
8 | * ```
9 | *
10 | * ```bash
11 | * yarn add @differentialhq/core
12 | * ```
13 | *
14 | * ```bash
15 | * pnpm add @differentialhq/core
16 | * ```
17 | */
18 |
19 | export { Differential } from "./Differential";
20 |
21 | export { CallConfig } from "./call-config";
22 |
--------------------------------------------------------------------------------
/sdk/src/serialize.test.ts:
--------------------------------------------------------------------------------
1 | import crypto from "crypto";
2 | import { pack, unpack } from "./serialize";
3 |
4 | describe("serailizing and deserializing", () => {
5 | const values = [
6 | 1,
7 | "string",
8 | true,
9 | false,
10 | null,
11 | undefined,
12 | { hello: "Bob" },
13 | Buffer.from("hello"),
14 | [1, 2, 3],
15 | new Date(),
16 | ];
17 |
18 | values.forEach((value) => {
19 | it(`should serialise ${value}`, () => {
20 | expect(unpack(pack(value, true))).toEqual(value);
21 | });
22 | });
23 | });
24 |
25 | describe("encryption", () => {
26 | const cryptoSettings = {
27 | keys: [Buffer.from("abcdefghijklmnopqrstuvwxzy123456")],
28 | };
29 |
30 | it("should encrypt and decrypt", () => {
31 | const encrypted = pack({ hello: "Bob" }, true, { cryptoSettings });
32 | const decrypted = unpack(encrypted, { cryptoSettings });
33 |
34 | expect(decrypted).toEqual({ hello: "Bob" });
35 | });
36 |
37 | it("should be able to encrypt and decrypt when keys roll", () => {
38 | const encrypted = pack({ hello: "Bob" }, true, { cryptoSettings });
39 |
40 | const newCryptoSettings = {
41 | keys: [crypto.randomBytes(32), ...cryptoSettings.keys],
42 | };
43 |
44 | const decrypted = unpack(encrypted, { cryptoSettings: newCryptoSettings });
45 |
46 | expect(decrypted).toEqual({ hello: "Bob" });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/sdk/src/task-queue.ts:
--------------------------------------------------------------------------------
1 | import debug from "debug";
2 | import { serializeError } from "./serialize-error";
3 | import { AsyncFunction } from "./types";
4 |
5 | const log = debug("differential:client");
6 |
7 | export type Result = {
8 | content: T;
9 | type: "resolution" | "rejection";
10 | functionExecutionTime?: number;
11 | };
12 |
13 | export const executeFn = async (
14 | fn: AsyncFunction["func"],
15 | args: Parameters,
16 | ): Promise => {
17 | const start = Date.now();
18 | try {
19 | const result = await fn(...args);
20 |
21 | return {
22 | content: result,
23 | type: "resolution",
24 | functionExecutionTime: Date.now() - start,
25 | };
26 | } catch (e) {
27 | const functionExecutionTime = Date.now() - start;
28 | if (e instanceof Error) {
29 | return {
30 | content: serializeError(e),
31 | type: "rejection",
32 | functionExecutionTime,
33 | };
34 | } else if (typeof e === "string") {
35 | return {
36 | content: serializeError(new Error(e)),
37 | type: "rejection",
38 | functionExecutionTime,
39 | };
40 | } else {
41 | return {
42 | content: new Error(
43 | "Differential encountered an unexpected error type. Make sure you are throwing an Error object.",
44 | ),
45 | type: "rejection",
46 | functionExecutionTime,
47 | };
48 | }
49 | }
50 | };
51 |
52 | export class TaskQueue {
53 | private tasks: Array<{
54 | fn: AsyncFunction["func"];
55 | args: Parameters;
56 | resolve: (value: Result) => void;
57 | }> = [];
58 |
59 | addTask(
60 | fn: AsyncFunction["func"],
61 | args: Parameters,
62 | resolve: (value: Result) => void,
63 | ) {
64 | this.tasks.push({
65 | fn,
66 | args,
67 | resolve,
68 | });
69 |
70 | this.run();
71 | }
72 |
73 | private run() {
74 | const tasks = this.tasks;
75 | this.tasks = [];
76 |
77 | Promise.all(
78 | tasks.map((task) => executeFn(task.fn, task.args).then(task.resolve)),
79 | );
80 | }
81 |
82 | async quit() {
83 | return new Promise((resolve) => {
84 | if (this.tasks.length === 0) {
85 | resolve();
86 | } else {
87 | const interval = setInterval(() => {
88 | if (this.tasks.length === 0) {
89 | clearInterval(interval);
90 | resolve();
91 | }
92 | }, 100);
93 | }
94 | });
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/sdk/src/tests/e2ee/d.ts:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | import { Differential } from "../../Differential";
4 |
5 | if (!process.env.DIFFERENTIAL_API_SECRET) {
6 | throw new Error("Missing env DIFFERENTIAL_API_SECRET");
7 | }
8 |
9 | export const d = new Differential(process.env.DIFFERENTIAL_API_SECRET, {
10 | encryptionKeys: [Buffer.from("abcdefghijklmnopqrstuvwxzy123456")],
11 | endpoint: process.env.DIFFERENTIAL_API_ENDPOINT_OVERRIDE,
12 | jobPollWaitTime: 5000,
13 | });
14 |
--------------------------------------------------------------------------------
/sdk/src/tests/e2ee/e2ee.test.ts:
--------------------------------------------------------------------------------
1 | import { d } from "./d";
2 | import { helloService } from "./hello";
3 |
4 | describe("e2ee", () => {
5 | it("should be able to call a service", async () => {
6 | await helloService.start();
7 |
8 | const result = await d
9 | .client("hello")
10 | .greet(["Bob", "Alice"]);
11 |
12 | expect(result).toEqual({
13 | result: "Hello Bob, Alice",
14 | names: ["Bob", "Alice"],
15 | });
16 |
17 | await helloService.stop();
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/sdk/src/tests/e2ee/hello.ts:
--------------------------------------------------------------------------------
1 | import { d } from "./d";
2 |
3 | export const greet = async (names: string[]) => {
4 | return {
5 | result: `Hello ${names.join(", ")}`,
6 | names,
7 | };
8 | };
9 |
10 | export const helloService = d.service({
11 | name: "hello",
12 | functions: {
13 | greet,
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/sdk/src/tests/errors/animals.ts:
--------------------------------------------------------------------------------
1 | import { d } from "./d";
2 |
3 | export const getNormalAnimal = async () => {
4 | throw new Error("This is a normal error");
5 | };
6 |
7 | export class AnimalError extends Error {
8 | constructor(message: string) {
9 | super(message);
10 | this.name = "AnimalError";
11 | }
12 | }
13 |
14 | export const getCustomAnimal = async () => {
15 | throw new AnimalError("This is a custom error");
16 | };
17 |
18 | export const animalService = d.service({
19 | name: "animal",
20 | functions: {
21 | getNormalAnimal,
22 | getCustomAnimal,
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/sdk/src/tests/errors/d.ts:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | import { Differential } from "../../Differential";
4 |
5 | if (!process.env.DIFFERENTIAL_API_SECRET) {
6 | throw new Error("Missing env DIFFERENTIAL_API_SECRET");
7 | }
8 |
9 | export const d = new Differential(process.env.DIFFERENTIAL_API_SECRET, {
10 | endpoint: process.env.DIFFERENTIAL_API_ENDPOINT_OVERRIDE,
11 | jobPollWaitTime: 5000,
12 | });
13 |
--------------------------------------------------------------------------------
/sdk/src/tests/errors/errors.test.ts:
--------------------------------------------------------------------------------
1 | import { animalService } from "./animals";
2 | import { d } from "./d";
3 |
4 | describe("Errors", () => {
5 | beforeAll(async () => {
6 | await animalService.start();
7 | });
8 |
9 | afterAll(async () => {
10 | await animalService.stop();
11 | });
12 |
13 | it("should get the normal error", async () => {
14 | const client = d.client("animal");
15 |
16 | expect(client.getNormalAnimal()).rejects.toThrow("This is a normal error");
17 | });
18 |
19 | it("should get the custom error", async () => {
20 | const client = d.client("animal");
21 |
22 | try {
23 | await client.getCustomAnimal();
24 | expect(true).toBe(false);
25 | } catch (e: any) {
26 | expect(e.name).toBe("AnimalError");
27 | expect(e.name).not.toBe("Animal2Error");
28 | }
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/sdk/src/tests/monolith/d.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/src/tests/monolith/d.ts
--------------------------------------------------------------------------------
/sdk/src/tests/monolith/db.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/src/tests/monolith/db.ts
--------------------------------------------------------------------------------
/sdk/src/tests/monolith/differential-398308-5f1deaf67146.json:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/src/tests/monolith/differential-398308-5f1deaf67146.json
--------------------------------------------------------------------------------
/sdk/src/tests/monolith/expert.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/src/tests/monolith/expert.ts
--------------------------------------------------------------------------------
/sdk/src/tests/monolith/facade.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/src/tests/monolith/facade.ts
--------------------------------------------------------------------------------
/sdk/src/tests/monolith/monolith.test.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/src/tests/monolith/monolith.test.ts
--------------------------------------------------------------------------------
/sdk/src/tests/monolith/run.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/differentialhq/differential/f521a42b65d2bef33dbf69d8537238ab6465f7e4/sdk/src/tests/monolith/run.ts
--------------------------------------------------------------------------------
/sdk/src/tests/utility/caching.test.ts:
--------------------------------------------------------------------------------
1 | import { d } from "./d";
2 | import { productService } from "./product";
3 |
4 | describe("Caching", () => {
5 | beforeAll(async () => {
6 | await productService.start();
7 | }, 10000);
8 |
9 | afterAll(async () => {
10 | await productService.stop();
11 | });
12 |
13 | it("should get the cached results when possible", async () => {
14 | const client = d.client("product");
15 |
16 | const productId = Math.random().toString();
17 |
18 | const result1 = await client.getProduct(productId, "foo", {
19 | $d: {
20 | cache: {
21 | key: productId,
22 | ttlSeconds: 10,
23 | },
24 | },
25 | });
26 |
27 | const result2 = await client.getProduct(productId, "bar", {
28 | $d: {
29 | cache: {
30 | key: productId,
31 | ttlSeconds: 10,
32 | },
33 | },
34 | });
35 |
36 | expect(result1).toEqual(result2);
37 | });
38 |
39 | it("should respect cache ttl", async () => {
40 | const client = d.client("product");
41 |
42 | const productId = Math.random().toString();
43 |
44 | const result1 = await client.getProduct(productId, "foo", {
45 | $d: {
46 | cache: {
47 | key: productId,
48 | ttlSeconds: 1,
49 | },
50 | },
51 | });
52 |
53 | await new Promise((resolve) => setTimeout(resolve, 2000)); // wait for cache to expire
54 |
55 | const result2 = await client.getProduct(productId, "bar", {
56 | $d: {
57 | cache: {
58 | key: productId,
59 | ttlSeconds: 1,
60 | },
61 | },
62 | });
63 |
64 | expect(result1).not.toEqual(result2);
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/sdk/src/tests/utility/d.ts:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | import { Differential } from "../../Differential";
4 |
5 | if (!process.env.DIFFERENTIAL_API_SECRET) {
6 | throw new Error("Missing env DIFFERENTIAL_API_SECRET");
7 | }
8 |
9 | export const d = new Differential(process.env.DIFFERENTIAL_API_SECRET, {
10 | endpoint: process.env.DIFFERENTIAL_API_ENDPOINT_OVERRIDE,
11 | jobPollWaitTime: 5000,
12 | });
13 |
--------------------------------------------------------------------------------
/sdk/src/tests/utility/product.ts:
--------------------------------------------------------------------------------
1 | import { d } from "./d";
2 |
3 | const cache = new Map();
4 |
5 | export const getProduct = async (id: string, random: string) => {
6 | return {
7 | id,
8 | name: `Product ${id}`,
9 | random,
10 | };
11 | };
12 |
13 | export const succeedsOnSecondAttempt = async (id: string) => {
14 | if (cache.has(id)) {
15 | return true;
16 | } else {
17 | cache.set(id, true);
18 | // wait 5s and time out
19 | await new Promise((resolve) => setTimeout(resolve, 60000));
20 | }
21 | };
22 |
23 | export const productService = d.service({
24 | name: "product",
25 | functions: {
26 | getProduct,
27 | succeedsOnSecondAttempt,
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/sdk/src/tests/utility/retry.test.ts:
--------------------------------------------------------------------------------
1 | import { DifferentialError } from "../../errors";
2 | import { d } from "./d";
3 | import { productService } from "./product";
4 |
5 | describe("retrying", () => {
6 | beforeAll(async () => {
7 | await productService.start();
8 | });
9 |
10 | afterAll(async () => {
11 | await productService.stop();
12 | });
13 |
14 | it("should not retry a function when attempts is 1", async () => {
15 | const client = d.client("product");
16 |
17 | const productId = Math.random().toString();
18 |
19 | await expect(
20 | client.succeedsOnSecondAttempt(productId, {
21 | $d: {
22 | retryCountOnStall: 0,
23 | timeoutSeconds: 5,
24 | },
25 | }),
26 | ).rejects.toThrow(DifferentialError.EXECUTION_DID_NOT_COMPLETE);
27 | });
28 |
29 | it("should be able to retry a function", async () => {
30 | const client = d.client("product");
31 |
32 | const productId = Math.random().toString();
33 |
34 | const result = await client.succeedsOnSecondAttempt(productId, {
35 | $d: {
36 | retryCountOnStall: 2,
37 | timeoutSeconds: 5,
38 | },
39 | });
40 |
41 | expect(result).toBe(true);
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/sdk/src/types.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export type AsyncFunction = {
4 | input: z.ZodObject;
5 | func: (args: Args) => Promise