├── example ├── build.rbxm ├── .pesde │ └── scripts │ │ ├── sourcemap_generator.luau │ │ └── roblox_sync_config_generator.luau ├── src │ ├── server │ │ ├── init.server.luau │ │ ├── roll.luau │ │ ├── sessions.luau │ │ └── trade.luau │ ├── shared │ │ ├── net │ │ │ ├── init.luau │ │ │ └── client.luau │ │ ├── weighted_table.luau │ │ └── unlockeable.luau │ └── client │ │ ├── ui │ │ ├── components │ │ │ ├── text.luau │ │ │ ├── screengui.luau │ │ │ ├── background.luau │ │ │ ├── button.luau │ │ │ └── hud.luau │ │ ├── state.luau │ │ └── effects.luau │ │ └── init.client.luau ├── README.md ├── pesde.toml ├── default.project.json ├── pesde.lock └── network.zap ├── src ├── datastoreservice.luau ├── log.luau ├── init.luau ├── task.luau ├── guid.luau ├── flags.luau ├── lib.luau ├── retry.luau ├── store.luau ├── budget.luau ├── lib.d.ts ├── view.luau ├── checktransaction.luau ├── throw.luau ├── actions.luau ├── transaction.luau ├── types.d.ts ├── util.luau ├── types.luau └── session.luau ├── .prettierrc ├── default.project.json ├── .pesde └── scripts │ ├── sourcemap_generator.luau │ └── roblox_sync_config_generator.luau ├── todo.md ├── .vscode └── settings.json ├── README.md ├── .gitignore ├── rokit.toml ├── docs ├── package.json ├── resources │ ├── getting-started │ │ ├── 1-why.md │ │ ├── 2-installation.md │ │ └── 3-setting-up.md │ └── advanced-concepts │ │ ├── migrations.md │ │ ├── strict-mode.md │ │ ├── transactions.md │ │ └── model.md ├── .vitepress │ ├── theme │ │ ├── index.ts │ │ └── style.css │ └── config.ts ├── index.md └── api │ ├── view.md │ ├── store.md │ ├── skilift.md │ └── session.md ├── wally.toml ├── .eslintrc ├── .github └── workflows │ ├── unit-test.yml │ ├── build.yml │ └── deploy.yml ├── pesde.toml ├── LICENSE ├── test ├── task.luau ├── testkit.luau ├── mock.luau └── tests.luau └── package.json /example/build.rbxm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicesaidhi/skilift/HEAD/example/build.rbxm -------------------------------------------------------------------------------- /src/datastoreservice.luau: -------------------------------------------------------------------------------- 1 | local flags = require(script.Parent.flags) 2 | 3 | return flags.datastoreservice -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "trailingComma": "all", 5 | "useTabs": true 6 | } -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skilift", 3 | 4 | "tree": { 5 | "$path": "src" 6 | } 7 | } -------------------------------------------------------------------------------- /.pesde/scripts/sourcemap_generator.luau: -------------------------------------------------------------------------------- 1 | return require("./../../lune_packages/.pesde/pesde+scripts_rojo/0.1.0/scripts_rojo/sourcemap_generator") -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # todo 2 | 3 | - [ ] introduce purchase actions 4 | - [ ] reduce reconcile updates on prod 5 | - [ ] defer patch updated calls 6 | -------------------------------------------------------------------------------- /example/.pesde/scripts/sourcemap_generator.luau: -------------------------------------------------------------------------------- 1 | return require("./../../lune_packages/.pesde/pesde+scripts_rojo/0.1.0/scripts_rojo/sourcemap_generator") -------------------------------------------------------------------------------- /.pesde/scripts/roblox_sync_config_generator.luau: -------------------------------------------------------------------------------- 1 | return require("./../../lune_packages/.pesde/pesde+scripts_rojo/0.1.0/scripts_rojo/roblox_sync_config_generator") -------------------------------------------------------------------------------- /example/src/server/init.server.luau: -------------------------------------------------------------------------------- 1 | local _roll = require(script.roll) 2 | local _sessions = require(script.sessions) 3 | local trade = require(script.trade) 4 | -------------------------------------------------------------------------------- /example/.pesde/scripts/roblox_sync_config_generator.luau: -------------------------------------------------------------------------------- 1 | return require("./../../lune_packages/.pesde/pesde+scripts_rojo/0.1.0/scripts_rojo/roblox_sync_config_generator") -------------------------------------------------------------------------------- /src/log.luau: -------------------------------------------------------------------------------- 1 | local flags = require("./flags") 2 | 3 | local function LOG(...) 4 | if flags.print == false then return end 5 | print("[skilift]:", ...) 6 | end 7 | 8 | return LOG -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[markdown]": { 3 | "editor.wrappingStrategy": "simple", 4 | "editor.wordWrap": "bounded", 5 | "editor.wordWrapColumn": 80 6 | } 7 | } -------------------------------------------------------------------------------- /src/init.luau: -------------------------------------------------------------------------------- 1 | local lib = require(script.lib) 2 | 3 | export type Store = lib.Store 4 | export type View = lib.View 5 | export type Session = lib.Session 6 | 7 | return lib -------------------------------------------------------------------------------- /src/task.luau: -------------------------------------------------------------------------------- 1 | 2 | 3 | if not task then 4 | return require("../test/task") 5 | else 6 | return { 7 | spawn = task.spawn, 8 | wait = task.wait, 9 | cancel = task.cancel, 10 | step = function() end 11 | } 12 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Skilift 2 | 3 | Skilift is a modern lockless data storage library with transactions. It does not use session locks, guaranteeing that players will be able to access their data aslong as DataStoreService is available. 4 | 5 | [Documentation](https://alicesaidhi.github.io/skilift/) -------------------------------------------------------------------------------- /example/src/shared/net/init.luau: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService "RunService" 2 | 3 | type Server = typeof(require(script.server)) 4 | type Client = typeof(require(script.client)) 5 | 6 | return if RunService:IsServer() 7 | then require(script.server) :: Server & Client 8 | else require(script.client) :: Server & Client 9 | -------------------------------------------------------------------------------- /src/guid.luau: -------------------------------------------------------------------------------- 1 | local MAP = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"} 2 | 3 | local function generate_guid() 4 | local b = buffer.create(32) 5 | 6 | for i = 0, 31 do 7 | buffer.writeu8(b, i, string.byte(MAP[math.random(1, 16)])) 8 | end 9 | 10 | return buffer.tostring(b) 11 | end 12 | 13 | return generate_guid -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /out 2 | /docs/node_modules/ 3 | /docs/.vitepress/cache 4 | /docs/.vitepress/dist 5 | 6 | /luau_packages 7 | /lune_packages 8 | /node_modules 9 | /package-lock.json 10 | /pesde.lock 11 | /build.rbxm 12 | 13 | /example/luau_packages 14 | /example/lune_packages 15 | /example/roblox_packages/ 16 | /example/roblox_server_packages/ 17 | /example/sourcemap.json 18 | 19 | **/sourcemap.json -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | A tiny RNG game to test out skilift 4 | 5 | ## Getting Started 6 | To build the place from scratch, use: 7 | 8 | ```bash 9 | rojo build -o "example.rbxlx" 10 | ``` 11 | 12 | Next, open `example.rbxlx` in Roblox Studio and start the Rojo server: 13 | 14 | ```bash 15 | rojo serve 16 | ``` 17 | 18 | For more help, check out [the Rojo documentation](https://rojo.space/docs). -------------------------------------------------------------------------------- /rokit.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Rokit, a toolchain manager for Roblox projects. 2 | # For more information, see https://github.com/rojo-rbx/rokit 3 | 4 | # New tools can be added by running `rokit add ` in a terminal. 5 | 6 | [tools] 7 | rojo = "rojo-rbx/rojo@7.4.4" 8 | pesde = "daimond113/pesde@0.6.2+registry.0.2.2" 9 | wally = "upliftgames/wally@0.3.2" 10 | lune = "lune-org/lune@0.8.9" 11 | zap = "red-blox/zap@0.6.16" 12 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "docs:dev": "vitepress dev", 5 | "docs:build": "vitepress build", 6 | "docs:preview": "vitepress preview" 7 | }, 8 | "devDependencies": { 9 | "@nolebase/vitepress-plugin-highlight-targeted-heading": "^2.12.0", 10 | "vitepress": "^1.4.5", 11 | "vitepress-plugin-tabs": "^0.5.0", 12 | "vitepress-theme-api": "^0.1.7" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/flags.luau: -------------------------------------------------------------------------------- 1 | local types = require("./types") 2 | 3 | type TransactionInfo = types.TransactionInfo 4 | 5 | local function inline_test(): string 6 | return debug.info(1, "n") 7 | end 8 | 9 | local is_O2 = inline_test() ~= "inline_test" 10 | 11 | return { 12 | processing_transaction = false :: false | TransactionInfo, 13 | disable_new_actions = false, 14 | strict = not is_O2, 15 | print = false, 16 | 17 | datastoreservice = if game then game:GetService("DataStoreService") else nil 18 | } -------------------------------------------------------------------------------- /example/src/client/ui/components/text.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local vide = require(ReplicatedStorage.packages.vide) 4 | 5 | local create = vide.create 6 | 7 | type Text = { 8 | text: string | () -> string, 9 | } 10 | 11 | return function(props: Text) 12 | return create "TextLabel" { 13 | Size = UDim2.fromScale(1, 1), 14 | 15 | TextSize = 16, 16 | Font = Enum.Font.BuilderSans, 17 | Text = props.text, 18 | 19 | } 20 | end -------------------------------------------------------------------------------- /example/src/client/ui/components/screengui.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local vide = require(ReplicatedStorage.packages.vide) 4 | 5 | local create = vide.create 6 | 7 | type ScreenGui = { 8 | name: string, 9 | enabled: boolean | () -> boolean, 10 | 11 | [number]: any 12 | } 13 | 14 | return function(props: ScreenGui) 15 | return create "ScreenGui" { 16 | Name = props.name, 17 | Enabled = props.enabled, 18 | 19 | unpack(props) 20 | } 21 | end -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "alicesaidhi/skilift" 3 | description = "Lockless DataStore library" 4 | license = "MIT" 5 | version = "0.3.1" 6 | registry = "https://github.com/UpliftGames/wally-index" 7 | realm = "shared" 8 | exclude = ["luau_packages", "lune_packages", "out", ".vscode", ".pesde", "build.project.json", "pesde.toml", "pesde.lock", "rokit.toml", "test", "docs", "node_modules", "package.json", "package-lock.json", ".prettierrc", ".eslintrc", "example"] 9 | include = ["src", "default.project.json", "LICENSE"] 10 | 11 | [dependencies] 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2018, 5 | "sourceType": "module", 6 | "project": "./tsconfig.json" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint", 10 | "roblox-ts", 11 | "prettier" 12 | ], 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:roblox-ts/recommended", 17 | "plugin:prettier/recommended" 18 | ], 19 | "rules": { 20 | "prettier/prettier": "warn", 21 | "@typescript-eslint/no-explicit-any": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /docs/resources/getting-started/1-why.md: -------------------------------------------------------------------------------- 1 | --- 2 | prev: false 3 | next: 4 | text: Installation 5 | link: ./2-installation/ 6 | --- 7 | 8 | # Why 9 | 10 | Skilift is a lockless library with transactions included. It's built on a different model that makes it possible to easily support transactions and edit the same key on multiple different servers by using atomic operations. 11 | 12 | Since the mental model is different from other libraries, as skilift is built around the idea that you reconcile data into your game constantly where skilift acts as a source-of-truth. This makes it safer than other libraries. 13 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: unit-test 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | unit-test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v3 12 | 13 | - name: Install Luau zip 14 | uses: robinraju/release-downloader@v1.6 15 | with: 16 | repository: Roblox/luau 17 | fileName: luau-ubuntu.zip 18 | tag: "0.651" 19 | out-file-path: bin 20 | 21 | - name: Unzip Luau 22 | run: unzip bin/luau-ubuntu.zip -d bin 23 | 24 | - name: Run unit tests 25 | run: bin/luau test/tests.luau -------------------------------------------------------------------------------- /example/src/shared/weighted_table.luau: -------------------------------------------------------------------------------- 1 | 2 | type WeightTable = { 3 | [T]: { 4 | weight: number 5 | } 6 | } 7 | 8 | local function roll(weights: WeightTable): T 9 | local max = 0 10 | 11 | for _, value in weights do 12 | max += value.weight 13 | end 14 | 15 | local roll = math.random(max) 16 | 17 | for key, value in weights do 18 | print(roll) 19 | roll -= value.weight 20 | if roll > 0 then continue end 21 | print("got", key) 22 | return key 23 | end 24 | 25 | error("did not roll anything") 26 | end 27 | 28 | return { 29 | roll = roll 30 | } -------------------------------------------------------------------------------- /example/src/shared/unlockeable.luau: -------------------------------------------------------------------------------- 1 | return { 2 | 3 | blue = { 4 | name = "blue", 5 | weight = 5 6 | }, 7 | 8 | yellow = { 9 | name = "blue", 10 | weight = 5 11 | }, 12 | 13 | red = { 14 | name = "red", 15 | weight = 5, 16 | }, 17 | 18 | green = { 19 | name = "green", 20 | weight = 4 21 | }, 22 | 23 | purple = { 24 | name = "purple", 25 | weight = 4 26 | }, 27 | 28 | pink = { 29 | name = "pink", 30 | weight = 2 31 | }, 32 | 33 | magenta = { 34 | name = "magenta", 35 | weight = 1 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /docs/resources/getting-started/2-installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | prev: 3 | text: Why 4 | link: ./1-why/ 5 | next: 6 | text: Installation 7 | link: ./3-setting-up/ 8 | --- 9 | 10 | # Getting Started 11 | 12 | This goes over how to start setting up skilift. 13 | 14 | ## Installation 15 | 16 | :::tabs 17 | ==Wally 18 | In your `wally.toml`, add the following line to your dependencies. 19 | 20 | ```toml 21 | skilift = "alicesaidhi/skilift@0.3.1-rc.1" 22 | ``` 23 | 24 | ==pesde 25 | In your terminal, run the following command. 26 | 27 | ```sh 28 | pesde add alicesaidhi/skilift 29 | ``` 30 | 31 | ==rbxm 32 | Download the latest rbxm from Github Releases and add it to your Studio. 33 | ::: 34 | -------------------------------------------------------------------------------- /example/src/server/roll.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local sessions = require(script.Parent.sessions) 4 | local net = require(ReplicatedStorage.shared.net) 5 | local unlockeable = require(ReplicatedStorage.shared.unlockeable) 6 | local weighted_table = require(ReplicatedStorage.shared.weighted_table) 7 | 8 | local function roll_item(player: Player) 9 | local key = weighted_table.roll(unlockeable) 10 | local value = unlockeable[key] 11 | local session = sessions.sessions[player] 12 | 13 | net.obtain_item.fire(player, { obtained = value.name }) 14 | session:patch(sessions.actions.give_item, value.name, 1) 15 | end 16 | 17 | net.roll_item.set_callback(roll_item) 18 | 19 | return { 20 | roll_item = roll_item 21 | } -------------------------------------------------------------------------------- /pesde.toml: -------------------------------------------------------------------------------- 1 | name = "alicesaidhi/skilift" 2 | version = "0.3.1" 3 | description = "Lockless DataStore library" 4 | authors = ["alicesaidhi"] 5 | repository = "https://github.com/alicesaidhi/skilift" 6 | license = "MIT" 7 | includes = [ 8 | "src/**", 9 | "LICENSE", 10 | "README.md", 11 | "pesde.toml" 12 | ] 13 | 14 | [target] 15 | environment = "roblox" 16 | lib = "src/init.luau" 17 | build_files = ["src"] 18 | 19 | [indices] 20 | default = "https://github.com/pesde-pkg/index" 21 | 22 | [scripts] 23 | roblox_sync_config_generator = ".pesde/scripts/roblox_sync_config_generator.luau" 24 | sourcemap_generator = ".pesde/scripts/sourcemap_generator.luau" 25 | 26 | [dev_dependencies] 27 | scripts = { name = "pesde/scripts_rojo", version = "^0.1.0", target = "lune" } 28 | rojo = { name = "pesde/rojo", version = "^7.4.4", target = "lune" } 29 | -------------------------------------------------------------------------------- /src/lib.luau: -------------------------------------------------------------------------------- 1 | local types = require("./types") 2 | local store = require("./store") 3 | local transaction = require("./transaction") 4 | local checktransaction = require("./checktransaction") 5 | local flags = require("./flags") 6 | 7 | export type Store = types.Store 8 | export type View = types.View 9 | export type Session = types.Session 10 | 11 | return { 12 | store = store, 13 | 14 | transaction = transaction.create_transaction, 15 | 16 | flush = checktransaction.flush_transactions, 17 | 18 | strict = function(value: boolean?) 19 | if value ~= nil then 20 | flags.strict = value 21 | end 22 | 23 | return flags.strict 24 | end, 25 | 26 | mock = function(value) 27 | flags.datastoreservice = value 28 | end 29 | 30 | } -------------------------------------------------------------------------------- /example/src/client/ui/state.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local vide = require(ReplicatedStorage.packages.vide) 4 | 5 | local source = vide.source 6 | 7 | return { 8 | inventory = source ({} :: { [string]: number }), 9 | obtained = source(false) :: vide.source, 10 | 11 | players = source({} :: {Player}), 12 | 13 | trading_with = source(false) :: vide.source, 14 | 15 | trading_finalized = source(false), 16 | trading_they_finalized = source(false), 17 | trading_ready = source(false), 18 | trading_they_ready = source(false), 19 | trading_cooldown = source(false) :: vide.source, 20 | 21 | trading_my_offer = source ({} :: { [string]: number }), 22 | trading_their_offer = source ({} :: { [string]: number }) 23 | } -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { h, onMounted, onUnmounted } from "vue"; 2 | import "./style.css"; 3 | import {DividePage} from 'vitepress-theme-api'; 4 | import { Theme } from "vitepress"; 5 | import DefaultTheme from "vitepress/theme"; 6 | import { 7 | NolebaseHighlightTargetedHeading 8 | } from '@nolebase/vitepress-plugin-highlight-targeted-heading/client' 9 | import { enhanceAppWithTabs } from 'vitepress-plugin-tabs/client' 10 | 11 | export default { 12 | extends: DefaultTheme, 13 | Layout: () => { 14 | return h(DefaultTheme.Layout, null, { 15 | 'layout-top': () => [ 16 | h(NolebaseHighlightTargetedHeading), 17 | ], 18 | }) 19 | }, 20 | enhanceApp({ app, router, siteData }) { 21 | // ... 22 | app.component("DividePage", DividePage); 23 | enhanceAppWithTabs(app); 24 | }, 25 | } satisfies Theme; -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [published] 5 | 6 | permissions: 7 | contents: write 8 | 9 | env: 10 | GH_TOKEN: ${{ github.token }} 11 | 12 | jobs: 13 | unit-test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Rokit 20 | uses: CompeyDev/setup-rokit@v0.1.2 21 | 22 | - name: Build rbxm and release 23 | run: | 24 | rojo build -o build.rbxm 25 | gh release upload ${{github.event.release.tag_name}} build.rbxm 26 | 27 | - name: Build example rbxm and release 28 | run: | 29 | cd example 30 | pesde install 31 | rojo build -o example_rng.rbxl 32 | gh release upload ${{github.event.release.tag_name}} example_rng.rbxl -------------------------------------------------------------------------------- /example/pesde.toml: -------------------------------------------------------------------------------- 1 | name = "alicesaidhi/skilift" 2 | version = "0.3.1-rc.1" 3 | private = true 4 | authors = ["alicesaidhi"] 5 | repository = "https://github.com/alicesaidhi/skilift" 6 | license = "MIT" 7 | 8 | [target] 9 | environment = "roblox" 10 | 11 | [indices] 12 | default = "https://github.com/pesde-pkg/index" 13 | 14 | [wally_indices] 15 | default = "https://github.com/UpliftGames/wally-index" 16 | 17 | [scripts] 18 | roblox_sync_config_generator = ".pesde/scripts/roblox_sync_config_generator.luau" 19 | sourcemap_generator = ".pesde/scripts/sourcemap_generator.luau" 20 | 21 | [dev_dependencies] 22 | scripts = { name = "pesde/scripts_rojo", version = "^0.1.0", target = "lune" } 23 | rojo = { name = "pesde/rojo", version = "^7.4.4", target = "lune" } 24 | 25 | [dependencies] 26 | vide = { wally = "wally#centau/vide", version = "^0.3.1" } 27 | skilift = { name = "alicesaidhi/skilift", version = "^0.3.1-rc.1" } 28 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | pageClass: home 5 | next: 6 | text: 'Introduction' 7 | link: '/tut/crash-course/1-introduction' 8 | 9 | hero: 10 | name: "skilift" 11 | text: Lockless Datastore Library 12 | tagline: Modern datastore library without session locking. 13 | image: 14 | src: /logo.svg 15 | actions: 16 | - theme: brand 17 | text: Learn 18 | link: /resources/getting-started/1-why/ 19 | - theme: alt 20 | text: API Reference 21 | link: /api/skilift/ 22 | - theme: alt 23 | text: Github 24 | link: https://github.com/alicesaidhi/skilift 25 | features: 26 | - title: No session locks 27 | details: Other servers can freely edit data, and everything remains synced. 28 | - title: Transactions 29 | details: Edit multiple sessions at the same time using transactions. 30 | --- 31 | -------------------------------------------------------------------------------- /docs/api/view.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # View 7 | 8 | Views are used to read the value of a key right now, or in the past. 9 | 10 | ## Methods 11 | 12 | ### latest 13 | 14 | Fetches the latest version of the key. 15 | 16 | - Type 17 | 18 | ```luau 19 | function view:latest(): T 20 | ``` 21 | 22 | - Details 23 | 24 | Always performs a GetAsync request. 25 | 26 | ### version 27 | 28 | Fetches a specific version of the key. 29 | 30 | - Type 31 | 32 | ```luau 33 | function view:version(version: string): T 34 | ``` 35 | 36 | - Details 37 | 38 | Always performs a GetVersionAsync request. 39 | 40 | ### timestamp 41 | 42 | Fetches version of the key at the given timestamp. 43 | 44 | - Type 45 | 46 | ```luau 47 | function view:timestamp(unix: number): T 48 | ``` 49 | 50 | - Details 51 | 52 | Always performs a GetVersionAtTimestampAsync request. 53 | -------------------------------------------------------------------------------- /example/src/client/ui/components/background.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local vide = require(ReplicatedStorage.packages.vide) 4 | 5 | local create = vide.create 6 | 7 | type Background = { 8 | name: string?, 9 | automatic_size: Enum.AutomaticSize?, 10 | 11 | size: (UDim2 | () -> UDim2)?, 12 | position: (UDim2 | () -> UDim2)?, 13 | anchor_point: (Vector2 | () -> Vector2)?, 14 | layout_order: (number | () -> number)?, 15 | zindex: (number | () -> number)?, 16 | 17 | [number]: any 18 | } 19 | 20 | return function(props: Background) 21 | return create "Frame" { 22 | Name = props.name, 23 | AutomaticSize = props.automatic_size, 24 | Size = props.size, 25 | Position = props.position, 26 | AnchorPoint = props.anchor_point, 27 | LayoutOrder = props.layout_order, 28 | ZIndex = props.zindex, 29 | 30 | unpack(props) 31 | } 32 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 alicesaidhi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /test/task.luau: -------------------------------------------------------------------------------- 1 | local pending = {} 2 | 3 | local function spawn(fn: (T...) -> (...any), ...: T...): thread 4 | local thread = coroutine.create(fn) 5 | coroutine.resume(thread, ...) 6 | return thread 7 | end 8 | 9 | local function wait(t: number?): number 10 | table.insert(pending, { time_left = t or 0, time_waited = 0, thread = coroutine.running() }) 11 | return coroutine.yield() 12 | end 13 | 14 | local function close(thread: thread) 15 | coroutine.close(thread) 16 | end 17 | 18 | local function step(dt: number?) 19 | for i = #pending, 1, -1 do 20 | local data = pending[i] 21 | 22 | data.time_left -= dt 23 | data.time_waited += dt 24 | 25 | if data.time_left <= 0 then 26 | pending[i] = pending[#pending] 27 | pending[#pending] = nil 28 | coroutine.resume(data.thread, data.time_waited) 29 | end 30 | end 31 | end 32 | 33 | return { 34 | spawn = spawn, 35 | wait = wait, 36 | cancel = close, 37 | step = step 38 | } -------------------------------------------------------------------------------- /src/retry.luau: -------------------------------------------------------------------------------- 1 | local task = require("./task") 2 | 3 | local EXPONENT_BACKOFF = 2 4 | 5 | local function exponential_backoff(fn: () -> T...): T... 6 | local delay = 3 7 | local result 8 | 9 | while true do 10 | local ok = pcall(function() 11 | result = { fn() } 12 | end) 13 | 14 | if not ok then 15 | task.wait(delay) 16 | delay *= EXPONENT_BACKOFF 17 | else 18 | break 19 | end 20 | end 21 | 22 | return unpack(result) 23 | end 24 | 25 | local function regular_retry(fn: () -> T..., delay: number, amount: number?): T... 26 | 27 | local i = 0 28 | local ok, result 29 | 30 | while ok ~= true and i ~= amount do 31 | ok, result = pcall(function() 32 | return { fn() } 33 | end) 34 | i += 1 35 | task.wait(1) 36 | end 37 | 38 | return unpack(result) 39 | 40 | end 41 | 42 | return { 43 | 44 | exponential_backoff = exponential_backoff, 45 | regular_retry = regular_retry 46 | 47 | } -------------------------------------------------------------------------------- /example/src/client/ui/components/button.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local vide = require(ReplicatedStorage.packages.vide) 4 | 5 | local create = vide.create 6 | 7 | type Background = { 8 | automatic_size: Enum.AutomaticSize?, 9 | 10 | size: (UDim2 | () -> UDim2)?, 11 | position: (UDim2 | () -> UDim2)?, 12 | anchor_point: (Vector2 | () -> Vector2)?, 13 | layout_order: (number | () -> number)?, 14 | zindex: (number | () -> number)?, 15 | 16 | activated: () -> (), 17 | 18 | [number]: any 19 | } 20 | 21 | return function(props: Background) 22 | return create "ImageButton" { 23 | AutomaticSize = props.automatic_size, 24 | Size = props.size or UDim2.fromOffset(100, 50), 25 | Position = props.position, 26 | AnchorPoint = props.anchor_point, 27 | LayoutOrder = props.layout_order, 28 | ZIndex = props.zindex, 29 | AutoButtonColor = true, 30 | BorderSizePixel = 1, 31 | 32 | Activated = props.activated, 33 | 34 | unpack(props) 35 | } 36 | end -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rbxts/skilift", 3 | "version": "0.3.1", 4 | "description": "A session lockless general datastore library", 5 | "main": "src/lib.luau", 6 | "types": "src/lib.d.ts", 7 | "homepage": "https://github.com/alicesaidhi/skilift", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/alicesaidhi/skilift.git" 11 | }, 12 | "directories": { 13 | "doc": "docs", 14 | "example": "example", 15 | "test": "test" 16 | }, 17 | "files": [ 18 | "LICENSE", 19 | "README.md", 20 | "src/*" 21 | ], 22 | "scripts": { 23 | "test": "echo \"Error: no test specified\" && exit 1" 24 | }, 25 | "author": "alicesaidhi", 26 | "license": "MIT", 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "devDependencies": { 31 | "@rbxts/compiler-types": "^3.0.0-types.0", 32 | "@rbxts/types": "^1.0.820", 33 | "eslint": "^9.20.0", 34 | "eslint-config-prettier": "^10.0.1", 35 | "eslint-plugin-prettier": "^5.2.3", 36 | "eslint-plugin-roblox-ts": "^0.0.36", 37 | "prettier": "^3.5.0", 38 | "roblox-ts": "^3.0.0", 39 | "typescript": "^5.7.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/store.luau: -------------------------------------------------------------------------------- 1 | local actions = require("./actions") 2 | local types = require("./types") 3 | local view = require("./view") 4 | local session = require("./session") 5 | local flags = require("./flags") 6 | 7 | type StoreOptions = types.StoreOptions 8 | type Store = types.Store 9 | 10 | local function add_action(self: Store, name, action) 11 | if not action then action = name; name = nil end 12 | if name then actions.register_transaction_action(self, action, name) else actions.register_action(self, action) end 13 | return action 14 | end 15 | 16 | local function get_store(options: StoreOptions): Store 17 | local store = { 18 | _options = options, 19 | _allowed_actions = {}, 20 | _transaction_actions = {}, 21 | _transaction_names = {}, 22 | _store = flags.datastoreservice:GetDataStore(options.name, options.scope) 23 | } 24 | 25 | store.action = actions.register_action 26 | store.transaction = actions.register_transaction_action 27 | 28 | store.load = session.load_session 29 | store.view = view.view_key 30 | 31 | return store 32 | end 33 | 34 | return get_store -------------------------------------------------------------------------------- /src/budget.luau: -------------------------------------------------------------------------------- 1 | local task = require("./task") 2 | local flags = require("./flags") 3 | 4 | local MINIMUM = 5 5 | 6 | type BudgetUsage = 7 | | "get" 8 | | "get_version" 9 | | "update" 10 | | "set" 11 | 12 | local function get_budget(key: BudgetUsage) 13 | if not game then return 1000 end 14 | 15 | local budget = flags.datastoreservice:GetRequestBudgetForRequestType( 16 | if key == "get" then Enum.DataStoreRequestType.GetAsync 17 | elseif key == "get_version" then Enum.DataStoreRequestType.GetVersionAsync 18 | elseif key == "update" then Enum.DataStoreRequestType.UpdateAsync 19 | elseif key == "set" then Enum.DataStoreRequestType.SetIncrementAsync 20 | else error(`{key} is not valid`) 21 | ) 22 | return budget 23 | end 24 | 25 | local function has_budget_and_delta(key: BudgetUsage, amount: number) 26 | return get_budget(key) - MINIMUM > amount 27 | end 28 | 29 | local function delay_until_meets_budget(key: BudgetUsage, minimum: number) 30 | while has_budget_and_delta(key, minimum) == false do 31 | task.wait() 32 | end 33 | end 34 | 35 | return { 36 | has_budget_and_delta = has_budget_and_delta, 37 | delay_until_meets_budget = delay_until_meets_budget, 38 | get_budget = get_budget 39 | } -------------------------------------------------------------------------------- /src/lib.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StoreOptions, 3 | Store, 4 | Session, 5 | View, 6 | Action, 7 | Migrations, 8 | Migration 9 | } from './types'; 10 | 11 | declare namespace Skilift { 12 | 13 | /** 14 | * Creates a new store. 15 | * @param options 16 | */ 17 | function store(options: StoreOptions): Store 18 | 19 | /** 20 | * Flushes cached transaction results 21 | */ 22 | function flush(): void 23 | 24 | /** 25 | * Enables strict mode. This is enabled by default in Roblox Studio. This 26 | * enables purity checks and json checks. 27 | * @param value 28 | */ 29 | function strict(value?: boolean): boolean 30 | 31 | /** 32 | * Initiates a transaction. It's recommended to escrow items first before 33 | * actually performing a transaction. 34 | * @param success 35 | * @param failure 36 | */ 37 | function transaction(success: () => void, failure?: () => void): boolean 38 | 39 | /** 40 | * Replaces all calls to DataStoreService with a provided mock interface of DataStoreService. 41 | * @param datastoreservice 42 | */ 43 | function mock(datastoreservice: DataStoreService): void 44 | } 45 | 46 | export = Skilift 47 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: site-deploy 2 | 3 | on: 4 | push: 5 | branches: [main] # todo: remove later 6 | 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: pages 16 | cancel-in-progress: false 17 | 18 | defaults: 19 | run: 20 | working-directory: docs 21 | 22 | jobs: 23 | build: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | - name: Setup Node 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: 18 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Install dependencies 37 | run: npm install 38 | - name: Build with VitePress 39 | run: npm run docs:build 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: docs/.vitepress/dist 44 | 45 | deploy: 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | needs: build 50 | runs-on: ubuntu-latest 51 | name: Deploy 52 | steps: 53 | - name: Deploy to GitHub Pages 54 | id: deployment 55 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /src/view.luau: -------------------------------------------------------------------------------- 1 | local types = require("./types") 2 | local retry = require("./retry") 3 | local budget = require("./budget") 4 | 5 | type StoredData = types.StoredData 6 | type Store = types.Store 7 | type View = types.View 8 | 9 | local function view_key(self: Store, key: string): View 10 | local view = {} 11 | local datastore = self._store 12 | 13 | function view:latest() 14 | local value: StoredData = retry.exponential_backoff(function() 15 | budget.delay_until_meets_budget("get", 1) 16 | return datastore:GetAsync(key) or { data = nil } 17 | end) 18 | 19 | return value.data 20 | end 21 | 22 | function view:version(version: string) 23 | local value: StoredData = retry.exponential_backoff(function() 24 | budget.delay_until_meets_budget("get_version", 1) 25 | return datastore:GetVersionAsync(key, version) or { data = nil } 26 | end) 27 | 28 | return value.data 29 | end 30 | 31 | function view:timestamp(timestamp: number) 32 | local value: StoredData = retry.exponential_backoff(function() 33 | budget.delay_until_meets_budget("get_version", 1) 34 | return datastore:GetVersionAtTimeAsync(key, timestamp) or { data = nil } 35 | end) 36 | 37 | return value.data 38 | end 39 | 40 | return view 41 | end 42 | 43 | return { 44 | view_key = view_key 45 | } -------------------------------------------------------------------------------- /docs/api/store.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # Store 7 | 8 | ## Methods 9 | 10 | ### load 11 | 12 | Loads a key and returns a new session actively pulling that key. 13 | 14 | - Type 15 | 16 | ```luau 17 | function store:load(key: string): Session 18 | ``` 19 | 20 | - Details 21 | 22 | Multiple sessions for the same key may exist at the same time. 23 | 24 | ### view 25 | 26 | Views a key and returns an object to read the latest version of that key or the value of the key in the past. 27 | 28 | - Type 29 | 30 | ```luau 31 | function store:view(key: string): View 32 | ``` 33 | 34 | ### action() 35 | 36 | Registers the given function as an action, allowing it to be used for patching data. 37 | 38 | - **Type** 39 | 40 | ```luau 41 | function store:action(fn: (T, ...unknown) -> T): (T, ...unknown) -> T 42 | ``` 43 | 44 | ### transaction() 45 | 46 | Registers the given function as an action, allowing it to be used for patching within a transaction. These actions are deferred and not applied immediately. Unlike regular actions, these actions must be provided with a **unique** name. 47 | 48 | - **Type** 49 | 50 | ```luau 51 | function skilift.action.transaction(fn: () -> (), name: string) 52 | ``` 53 | 54 | - **Details** 55 | 56 | The name must be unique. 57 | 58 | > [!WARNING] 59 | > Actions registered as a transaction should never be removed from your codebase. Removing a transaction may result in player data becoming corrupted and unreadable. 60 | -------------------------------------------------------------------------------- /example/default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "emitLegacyScripts": false, 4 | "tree": { 5 | "$className": "DataModel", 6 | 7 | "ReplicatedStorage": { 8 | "packages": { 9 | "$path": "roblox_packages" 10 | }, 11 | 12 | "shared": { 13 | "$path": "src/shared" 14 | }, 15 | 16 | "client": { 17 | "$path": "src/client" 18 | } 19 | }, 20 | 21 | "ServerScriptService": { 22 | "server": { 23 | "$path": "src/server" 24 | } 25 | }, 26 | 27 | "Workspace": { 28 | "$properties": { 29 | "FilteringEnabled": true 30 | }, 31 | "Baseplate": { 32 | "$className": "Part", 33 | "$properties": { 34 | "Anchored": true, 35 | "Color": [ 36 | 0.38823, 37 | 0.37254, 38 | 0.38823 39 | ], 40 | "Locked": true, 41 | "Position": [ 42 | 0, 43 | -10, 44 | 0 45 | ], 46 | "Size": [ 47 | 512, 48 | 20, 49 | 512 50 | ] 51 | } 52 | } 53 | }, 54 | "Lighting": { 55 | "$properties": { 56 | "Ambient": [ 57 | 0, 58 | 0, 59 | 0 60 | ], 61 | "Brightness": 2, 62 | "GlobalShadows": true, 63 | "Outlines": false, 64 | "Technology": "Voxel" 65 | } 66 | }, 67 | "SoundService": { 68 | "$properties": { 69 | "RespectFilteringEnabled": true 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /example/src/client/init.client.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | 4 | local hud = require(script.ui.components.hud) 5 | local vide = require(ReplicatedStorage.packages.vide) 6 | local net = require(ReplicatedStorage.shared.net) 7 | local state = require(script.ui.state) 8 | 9 | vide.mount(hud, Players.LocalPlayer.PlayerGui) 10 | 11 | net.obtain_item.set_callback(function(data) 12 | state.obtained(data.obtained) 13 | end) 14 | 15 | net.update_inventory.set_callback(function(data) 16 | print("update inventory to", data) 17 | state.inventory(data) 18 | end) 19 | 20 | net.set_item_offer.set_callback(function(offer) 21 | print("update offer", offer.item, offer.amount) 22 | state.trading_their_offer()[offer.item] = offer.amount 23 | state.trading_their_offer(state.trading_their_offer()) 24 | end) 25 | 26 | net.trade_rejected.set_callback(function(data) 27 | state.trading_with(false) 28 | state.trading_ready(false) 29 | state.trading_cooldown(false) 30 | state.trading_finalized(false) 31 | state.trading_my_offer({}) 32 | state.trading_their_offer({}) 33 | state.trading_they_finalized(false) 34 | 35 | end) 36 | 37 | net.start_trade.set_callback(function(data) 38 | state.trading_with(data.who) 39 | state.trading_ready(false) 40 | state.trading_cooldown(false) 41 | state.trading_finalized(false) 42 | state.trading_my_offer({}) 43 | state.trading_their_offer({}) 44 | state.trading_they_finalized(false) 45 | end) 46 | 47 | while task.wait() do 48 | state.players(Players:GetPlayers()) 49 | end -------------------------------------------------------------------------------- /example/pesde.lock: -------------------------------------------------------------------------------- 1 | name = "alicesaidhi/skilift" 2 | version = "0.2.2-rc.1" 3 | target = "roblox" 4 | 5 | [graph."alicesaidhi/skilift"."0.2.2-rc.1 roblox_server"] 6 | direct = ["skilift", { name = "alicesaidhi/skilift", version = "^0.2.2-rc.1", target = "roblox_server" }, "standard"] 7 | resolved_ty = "standard" 8 | 9 | [graph."alicesaidhi/skilift"."0.2.2-rc.1 roblox_server".target] 10 | environment = "roblox_server" 11 | lib = "src/init.luau" 12 | build_files = ["src"] 13 | 14 | [graph."alicesaidhi/skilift"."0.2.2-rc.1 roblox_server".pkg_ref] 15 | ref_ty = "pesde" 16 | name = "alicesaidhi/skilift" 17 | version = "0.2.2-rc.1" 18 | index_url = "https://github.com/pesde-pkg/index" 19 | 20 | [graph."alicesaidhi/skilift"."0.2.2-rc.1 roblox_server".pkg_ref.dependencies] 21 | rojo = [{ name = "pesde/rojo", version = "^7.4.4", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] 22 | scripts = [{ name = "pesde/scripts_rojo", version = "^0.1.0", index = "https://github.com/pesde-pkg/index", target = "lune" }, "dev"] 23 | 24 | [graph."alicesaidhi/skilift"."0.2.2-rc.1 roblox_server".pkg_ref.target] 25 | environment = "roblox_server" 26 | lib = "src/init.luau" 27 | build_files = ["src"] 28 | 29 | [graph."wally#centau/vide"."0.3.1 roblox"] 30 | direct = ["vide", { wally = "wally#centau/vide", version = "^0.3.1" }, "standard"] 31 | resolved_ty = "standard" 32 | 33 | [graph."wally#centau/vide"."0.3.1 roblox".target] 34 | environment = "roblox" 35 | lib = "____pesde_no_export_file_found" 36 | build_files = [] 37 | 38 | [graph."wally#centau/vide"."0.3.1 roblox".pkg_ref] 39 | ref_ty = "wally" 40 | wally = "wally#centau/vide" 41 | version = "0.3.1" 42 | index_url = "https://github.com/UpliftGames/wally-index" 43 | -------------------------------------------------------------------------------- /docs/api/skilift.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # skilift 7 | 8 | ## Functions 9 | 10 | ### store() 11 | 12 | Creates a new store. 13 | 14 | - **Type** 15 | 16 | ```luau 17 | function skilift.store(options: { 18 | name: string, 19 | scope: string?, 20 | default_data: () -> T 21 | migrations: { {step: string, migrate: (old: any) -> any} }, 22 | schema: ((unknown) -> boolean)?, 23 | pull_delay: number? 24 | }): Session 25 | ``` 26 | 27 | - **Details** 28 | 29 | default_data should always return unique data. When returning a table, make sure it's not referenced anywhere else. Schema validation through `t` or some other library can be added using the schema property. It's possible to manually change the delay between auto-saves / pulls with the pull_delay property. 30 | 31 | ### transaction() 32 | 33 | Initiates a transaction 34 | 35 | - **Type** 36 | 37 | ```luau 38 | function skilift.transaction( 39 | success: () -> (), 40 | failure: (() -> ())? 41 | ): boolean 42 | ``` 43 | 44 | - **Details** 45 | 46 | Returns a boolean indicating if the transaction was successful or not. View the transaction article in advanced concepts for more information about how to properly handle transactions. 47 | 48 | ### strict() 49 | 50 | Sets or reads the currently stored value for strict mode. 51 | 52 | - **Type** 53 | 54 | ```luau 55 | function skilift.strict(value: boolean?): boolean 56 | ``` 57 | 58 | - **Details** 59 | 60 | View the [strict mode](../resources/advanced-concepts/strict-mode.md) resource for more information on what strict mode does. 61 | -------------------------------------------------------------------------------- /docs/resources/advanced-concepts/migrations.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # Migrations 7 | 8 | More often than not, as a developer you have to start changing the data format that you use for player data. Skilift requires this as part of it's API. 9 | 10 | ## Setting up migrations 11 | 12 | Migrations are simple functions that transform data. They are only ran at most once on old data. They receive the old data and are expected to spit out data that either matches the current schema or can be fed into the next migration. 13 | 14 | > [!WARNING] 15 | > Migrations are permanent functions left to be in your codebase. They should never be removed, as removing a migration may result in corrupted data. 16 | 17 | The names provided with a migration are used to track if a migration happened. 18 | 19 | Here is an example migration to demonstrate adding fields. 20 | 21 | ```luau 22 | 23 | type OldPlayerData = { 24 | money: number 25 | } 26 | 27 | type NewPlayerData = { 28 | money: number, 29 | gems: number 30 | } 31 | 32 | local function add_gems_migrate(data: OldPlayerdata): NewPlayerData 33 | data.gems = 0 34 | return data 35 | end 36 | 37 | local player_data = skilift.store { 38 | name = "player-data", 39 | migrations = { 40 | { step = "add-gems-field", migrate = add_gems_migrate } 41 | }, 42 | default_data = function() 43 | return { 44 | money = 0, 45 | gems = 0 46 | } 47 | end 48 | } 49 | ``` 50 | 51 | When a session is loaded that does not have all migrations applied to it, it will apply any migration that hasn't been applied yet. Migrations are performed in the order they are provided. 52 | -------------------------------------------------------------------------------- /example/src/client/ui/effects.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local state = require(script.Parent.state) 4 | local net = require(ReplicatedStorage.shared.net) 5 | return { 6 | 7 | roll_item = function() 8 | net.roll_item.fire() 9 | end, 10 | 11 | start_trade = function(player) 12 | print("requesting to trade with", player) 13 | net.request_trade.fire(player) 14 | end, 15 | 16 | reject_trade = function() 17 | net.reject_trade.fire() 18 | state.trading_with(false) 19 | state.trading_ready(false) 20 | state.trading_cooldown(false) 21 | state.trading_finalized(false) 22 | state.trading_my_offer({}) 23 | state.trading_their_offer({}) 24 | state.trading_they_finalized(false) 25 | end, 26 | 27 | add = function(item: string) 28 | local offer = state.trading_my_offer() 29 | local n = state.inventory()[item] or -1 30 | 31 | offer[item] = offer[item] or 0 32 | offer[item] = math.clamp(offer[item] + 1, 0, n) 33 | 34 | net.set_item.fire({ amount = offer[item], item = item }) 35 | state.trading_my_offer(offer) 36 | end, 37 | 38 | remove = function(item: string) 39 | local offer = state.trading_my_offer() 40 | local n = state.inventory()[item] or -1 41 | 42 | offer[item] = offer[item] or 0 43 | offer[item] = math.clamp(offer[item] - 1, 0, n) 44 | 45 | net.set_item.fire({ amount = offer[item], item = item }) 46 | state.trading_my_offer(offer) 47 | end, 48 | 49 | ready = function() 50 | net.trade_ready.fire() 51 | end 52 | 53 | } -------------------------------------------------------------------------------- /example/network.zap: -------------------------------------------------------------------------------- 1 | -- NOTES TO OTHER DEVS: 2 | -- Always use SingleSync, no matter what. 3 | -- Events should only ever be listened to from one place inside a queue. 4 | opt casing = "snake_case" 5 | opt server_output = "src/shared/net/server.luau" 6 | opt client_output = "src/shared/net/client.luau" 7 | opt write_checks = false 8 | 9 | -- * Client 10 | 11 | event roll_item = { 12 | from: Client, 13 | type: Reliable, 14 | call: SingleSync, 15 | } 16 | 17 | event request_trade = { 18 | from: Client, 19 | type: Reliable, 20 | call: SingleSync, 21 | data: Instance 22 | } 23 | 24 | event reject_trade = { 25 | from: Client, 26 | type: Reliable, 27 | call: SingleSync 28 | } 29 | 30 | event set_item = { 31 | from: Client, 32 | type: Reliable, 33 | call: SingleSync, 34 | data: struct { 35 | item: string, 36 | amount: u32 37 | } 38 | } 39 | 40 | event trade_ready = { 41 | from: Client, 42 | type: Reliable, 43 | call: SingleSync, 44 | } 45 | 46 | -- * Server 47 | 48 | event start_trade = { 49 | from: Server, 50 | type: Reliable, 51 | call: SingleSync, 52 | data: struct { 53 | who: Instance 54 | } 55 | } 56 | 57 | event obtain_item = { 58 | from: Server, 59 | type: Reliable, 60 | call: SingleSync, 61 | data: struct { 62 | obtained: string 63 | } 64 | } 65 | 66 | event trade_rejected = { 67 | from: Server, 68 | type: Reliable, 69 | call: SingleSync 70 | } 71 | 72 | event update_inventory = { 73 | from: Server, 74 | type: Reliable, 75 | call: SingleSync, 76 | data: map { [string]: u8 } 77 | } 78 | 79 | event set_item_offer = { 80 | from: Server, 81 | type: Reliable, 82 | call: SingleSync, 83 | data: struct { 84 | item: string, 85 | amount: u32 86 | } 87 | } -------------------------------------------------------------------------------- /docs/resources/advanced-concepts/strict-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # Strict Mode 7 | 8 | Strict mode is intended to catch errors and bad code that would generally be considered too expensive to check for. It's enabled by default in Roblox Studio and automatically disables itself outside of studio. This can be toggled through `skilift.strict`. 9 | 10 | Currently, strict mode is limited to the following errors: 11 | 12 | ## Bad JSON 13 | 14 | Every single (currently only normal) action in skilift is checked to make sure it does not return invalid JSON. It errors whenever it encounters a datatype that cannot be serialized into JSON. 15 | 16 | This errors in cases where you have: 17 | 18 | - Non real numbers (nan, inf, -inf) 19 | - Bad Datatype (anything that isn't a buffer, bool, string, number, table) 20 | - Recursive table (a table that refers to itself somewhere down) 21 | - Non-string key dictionary (not all keys of a dictionary are a string) 22 | 23 | ```luau 24 | local function bad_action(data) 25 | data.money = 0/0 -- non-real number 26 | data.table = data -- recursive table 27 | data.bad_datatype = vector.create() -- bad datatype 28 | data.test = { [vector.create(0, 0, 0)] = "test" } -- non-string-key dictionary 29 | return data 30 | end 31 | ``` 32 | 33 | ## Impure actions 34 | 35 | Actions are ran twice and evaluated to determine if they yield the same result. It fails when it does not yield the same result. It's easiest to avoid by only using the values you pass into the action. 36 | 37 | ```luau 38 | local function impure_action(data) 39 | data.money = math.random() -- math.random is not deterministic, therefore making it impure! this will cause an error in strict mode 40 | return data 41 | end 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/resources/getting-started/3-setting-up.md: -------------------------------------------------------------------------------- 1 | --- 2 | prev: 3 | text: Installation 4 | link: ./2-installation/ 5 | next: 6 | text: Transactions 7 | link: ../advanced-concepts/transactions/ 8 | --- 9 | 10 | # Setting Up 11 | 12 | ## Obtaining a session 13 | 14 | You have to specify the default data. Stores allow you to obtain a session. 15 | 16 | ```luau 17 | local store = skilift.store { 18 | name = "player-data", 19 | migrations = {}, 20 | default_data = function() 21 | return { money = 0 } 22 | end 23 | } 24 | ``` 25 | 26 | ## Reconciling data from a session 27 | 28 | Skilift reconciles data frequently every 15 seconds. Your game state should never act as the source of truth for your data when using skilift. 29 | 30 | ```luau 31 | 32 | store:updated(function(new_data) 33 | player_data.money = new_data.money 34 | end) 35 | 36 | ``` 37 | 38 | ## Updating the data 39 | 40 | Your data is updated by atomic operations or actions as skilift refers to them. They are pure functions that operate on your data. The more specific an action is, the better. For example, it's better to use an `add_money` action for giving someone money than a `set_money` action as it's more specific. 41 | 42 | Actions need to be registered before they can be used. 43 | 44 | ```luau 45 | --- You don't want to use actions like this ideally. It's way too general and 46 | --- isn't as easily transferrable to new data. 47 | local function set_money(data: PlayerData, n: number) 48 | data.money = n 49 | return data 50 | end 51 | 52 | --- This is more specific and can easily be transferred to new data when pulled. 53 | local function add_money(data: PlayerData, n: number) 54 | data.money += n 55 | return data 56 | end 57 | 58 | store:action(set_money) 59 | store:action(add_money) 60 | 61 | store:patch(add_money, 100) 62 | ``` 63 | 64 | It's recommended to use `:patch` to update your data, as whenever your data is patched it also immediately fires the `:updated` function. 65 | -------------------------------------------------------------------------------- /src/checktransaction.luau: -------------------------------------------------------------------------------- 1 | local flags = require("./flags") 2 | local types = require("./types") 3 | local retry = require("./retry") 4 | local task = require("./task") 5 | 6 | local TRANSACTION_NAME = "_$SKILIFT_TRANSACTION" 7 | local TRANSACTION_SCOPE = "global" 8 | 9 | local TRANSACTION_MAX_LIFETIME = 60 * 90 10 | local MAX_STORED_TRANSACTIONS = 9000 11 | 12 | local cached_transaction_status = {} 13 | local cached_transaction_lifetime = {} 14 | 15 | type TransactionInfo = types.TransactionInfo 16 | type TransactionId = types.TransactionId 17 | type CompletionStatus = "failed" | "completed" | "unknown" 18 | 19 | local function set_transaction_status(id: TransactionId, ok: boolean, delay: number?) 20 | cached_transaction_status[id] = ok 21 | table.insert(cached_transaction_lifetime, { id = id, lifetime = os.clock() + TRANSACTION_MAX_LIFETIME }) 22 | end 23 | 24 | local function request_for_transaction(id: TransactionId) 25 | local TRANSACTION_STORE = flags.datastoreservice:GetDataStore(TRANSACTION_NAME, TRANSACTION_SCOPE) 26 | 27 | local result: boolean? = retry.exponential_backoff(function() 28 | return TRANSACTION_STORE:GetAsync(id) 29 | end) 30 | 31 | set_transaction_status(id, if result then true else false) 32 | end 33 | 34 | local function is_transaction_complete(id: TransactionId): CompletionStatus 35 | local value = cached_transaction_status[id] 36 | if value == true then 37 | return "completed" 38 | elseif value == false then 39 | return "failed" 40 | else 41 | task.spawn(request_for_transaction, id) 42 | if cached_transaction_status[id] then return "completed" end -- the transaction was stored in the cache 43 | return "unknown" 44 | end 45 | end 46 | 47 | local function flush_transactions() 48 | while 49 | cached_transaction_lifetime[1] 50 | and cached_transaction_lifetime[1].lifetime < os.clock() 51 | or #cached_transaction_lifetime > MAX_STORED_TRANSACTIONS 52 | do 53 | table.remove(cached_transaction_lifetime, 1) 54 | end 55 | end 56 | 57 | return { 58 | request_for_transaction = request_for_transaction, 59 | is_transaction_complete = is_transaction_complete, 60 | set_transaction_status = set_transaction_status, 61 | flush_transactions = flush_transactions 62 | } -------------------------------------------------------------------------------- /src/throw.luau: -------------------------------------------------------------------------------- 1 | local ERRORS = { 2 | transaction_invalid_action = { 3 | msg = `Regular action at %s:%s cannot be used inside a transaction for modifying data.` 4 | }, 5 | transaction_invalid_function = { 6 | msg = `Unregistered function at %s:%s cannot be used inside a transaction for modifying data.` 7 | }, 8 | action_invalid_function = { 9 | msg = `Unregistered function at %s:%s cannot be used for patching data.` 10 | }, 11 | action_attempt_transaction = { 12 | msg = `Action at %s:%s can only be used for patching within transactions.` 13 | }, 14 | cannot_make_action = { 15 | msg = `Unable to register a new action after the first session has been started.` 16 | }, 17 | unknown_transaction_action = { 18 | msg = `Unable to perform an action named %s?` 19 | }, 20 | action_does_not_return = { 21 | msg = `Action %s does not return any data.` 22 | }, 23 | migration_does_not_return = { 24 | msg = `Migration %s does not return any data.` 25 | }, 26 | 27 | strict_impure_action = { 28 | msg = `Action at %s:%s is impure and does not yield the same result. Path: %s` 29 | }, 30 | strict_not_json_from_action = { 31 | msg = `Action %s is not valid JSON: %s` 32 | }, 33 | strict_pass_not_json_into_action = { 34 | msg = `Actions within transactions can only accept valid JSON for parameters! (at %s)` 35 | }, 36 | 37 | schema_validation_error = { 38 | msg = `Schema validation failed on %s after action %s.` 39 | }, 40 | session_already_stopped = { 41 | msg = `Session already stopped!` 42 | }, 43 | 44 | cannot_cancel_action = { 45 | msg = `Cannot cancel the action "%s" by passing nil!` 46 | } 47 | } 48 | 49 | type Errors = 50 | | "transaction_invalid_action" 51 | | "transaction_invalid_function" 52 | | "action_invalid_function" 53 | | "action_attempt_transaction" 54 | | "cannot_make_action" 55 | | "unknown_transaction_action" 56 | | "action_does_not_return" 57 | | "migration_does_not_return" 58 | 59 | | "strict_impure_action" 60 | | "strict_not_json_from_action" 61 | | "strict_pass_not_json_into_action" 62 | | "schema_validation_error" 63 | | "session_already_stopped" 64 | | "cannot_cancel_action" 65 | 66 | local function THROW(err: Errors, ...: string) 67 | error(string.format(ERRORS[err].msg, ...), 2) 68 | end 69 | 70 | return THROW -------------------------------------------------------------------------------- /docs/resources/advanced-concepts/transactions.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # Transactions 7 | 8 | A problem developers often encounter is safely modifying multiple keys at the same time. A failure on any key should result in a failure for every other write, or you may have a dupe glitch. 9 | 10 | In order to perform a transaction, you can use the `skilift.transaction` api. It takes two functions and any patches you perform in these 2 functions will be deferred until it either succeeds or fails. 11 | 12 | > [!WARNING] 13 | > You're unable to use regular actions within a transaction. Instead, you use the `skilift.action.transaction` API to tell skilift you can use it inside a transaction. This is important since actions used in transactions should NEVER be removed. When you publish a transaction to production, removing it may result in player data becoming invalid. Further, actions for transactions need to receive a name, unlike regular actions. 14 | 15 | This demonstrates how to transfer money. 16 | 17 | ```luau 18 | local store = skilift.store { 19 | name = "player-data", 20 | migrations = {}, 21 | default_data = function() 22 | return { money = 100 } 23 | end 24 | } 25 | 26 | local bob = store:load("bob") 27 | local tom = store:load("tom") 28 | 29 | local function give_money(data: PlayerData, n: number) 30 | data.money += n 31 | return data 32 | end 33 | 34 | local function take_money(data: PlayerData, n: number) 35 | data.money -= n 36 | return data 37 | end 38 | 39 | store:action(give_money) 40 | store:action(take_money) 41 | 42 | store:transaction(give_money, "give_money") 43 | store:transaction(take_money, "take_money") 44 | 45 | -- when the transaction succeeds, it takes money from bob and gives money to tom 46 | local ok = skilift.transaction(function() 47 | bob:patch(take_money, 10) 48 | tom:patch(give_money, 10) 49 | end) 50 | ``` 51 | 52 | ## Escrowing 53 | 54 | This code is flawed though. Transactions don't process instantly, and you should escrow resources first to prevent users from still using that data. This prevents Bob from using their 10 coins before Tom receives their 10 coins. If it fails, you can just return the 10 coins to Bob. 55 | 56 | ```luau 57 | -- this is better, as bob won't be able to use his 10 dollars while it's 58 | -- processing, and tom only gets 10 coins when we're sure the transaction has 59 | -- succeeded. 60 | bob:patch(take_money, 10) 61 | local ok = skilift.transaction(function() 62 | tom:patch(give_money, 10) 63 | end, function() 64 | bob:patch(give_money, 10) 65 | end) 66 | ``` 67 | -------------------------------------------------------------------------------- /src/actions.luau: -------------------------------------------------------------------------------- 1 | local types = require("./types") 2 | local flags = require("./flags") 3 | local THROW = require("./throw") 4 | 5 | type Store = types.Store 6 | type Action = (old: T, U...) -> T 7 | 8 | local function ASSERT_ACTION(store: Store, action: Action) 9 | local transaction_actions = store._transaction_actions 10 | local actions = store._allowed_actions 11 | 12 | if flags.processing_transaction and not transaction_actions[action] and actions[action] then 13 | THROW("transaction_invalid_action", debug.info(action, "sl")) 14 | elseif flags.processing_transaction and not transaction_actions[action] then 15 | THROW("transaction_invalid_function", debug.info(action, "sl")) 16 | elseif not flags.processing_transaction and transaction_actions[action] and not actions[action] then 17 | THROW("action_attempt_transaction", debug.info(action, "sl")) 18 | elseif not flags.processing_transaction and not actions[action] then 19 | THROW("action_invalid_function", debug.info(action, "sl")) 20 | end 21 | end 22 | 23 | 24 | local function register_action(store: Store, action: Action) 25 | local actions = store._allowed_actions 26 | actions[action] = true 27 | return action 28 | end 29 | 30 | 31 | local function register_transaction_action(store: Store, name: string, action: Action) 32 | local transaction_actions = store._transaction_actions 33 | local transaction_names = store._transaction_names 34 | local actions = store._allowed_actions 35 | 36 | transaction_actions[action] = name 37 | transaction_names[name] = action 38 | actions[action] = true 39 | return action 40 | end 41 | 42 | local function create_record(action: Action, ...: any) 43 | return { action, ... } 44 | end 45 | 46 | local function create_transaction_record(store: Store, action: Action, ...: any) 47 | local transaction_actions = store._transaction_actions 48 | return { fn = transaction_actions[action], values = {...} } 49 | end 50 | 51 | local function obtain_transaction_action(store: Store, name: string) 52 | local transaction_names = store._transaction_names 53 | 54 | local action = transaction_names[name] 55 | if not action then THROW("unknown_transaction_action", name) end 56 | return action 57 | end 58 | 59 | return { 60 | ASSERT_ACTION = ASSERT_ACTION, 61 | 62 | register_action = register_action, 63 | register_transaction_action = register_transaction_action, 64 | 65 | create_record = create_record, 66 | create_transaction_record = create_transaction_record, 67 | 68 | obtain_transaction_action = obtain_transaction_action, 69 | } -------------------------------------------------------------------------------- /example/src/server/sessions.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | 4 | local skilift = require(ReplicatedStorage.packages.skilift) 5 | local net = require(ReplicatedStorage.shared.net) 6 | 7 | export type PlayerData = { 8 | items: { [string]: number } 9 | } 10 | 11 | local player_data_store = skilift.store { 12 | name = "player-data", 13 | migrations = {}, 14 | default_data = function(): PlayerData 15 | return { 16 | items = {} 17 | } 18 | end 19 | } 20 | 21 | local sessions: {[Player]: skilift.Session} = {} 22 | local player_data = {} 23 | 24 | do 25 | local function reconcile_data(player: Player) 26 | return function(data: PlayerData) 27 | player_data[player] = data 28 | net.update_inventory.fire(player, data.items) 29 | end 30 | end 31 | 32 | local function player_added(player: Player) 33 | local key = `player-{player.UserId}` 34 | local session = player_data_store:load(key) 35 | 36 | sessions[player] = session 37 | session:updated(reconcile_data(player)) 38 | 39 | session:await() 40 | if session:bad() then 41 | player:Kick(`player data retrieval error {session:bad()}`) 42 | end 43 | 44 | end 45 | 46 | local function player_removing(player: Player) 47 | if sessions[player] == nil then return end 48 | sessions[player]:stop() 49 | sessions[player]:await() 50 | sessions[player] = nil 51 | end 52 | 53 | local function bind_to_close() 54 | for key, session in sessions do 55 | session:stop() 56 | session:await() 57 | end 58 | end 59 | 60 | for _, player in Players:GetPlayers() do 61 | player_added(player) 62 | end 63 | 64 | Players.PlayerAdded:Connect(player_added) 65 | Players.PlayerRemoving:Connect(player_removing) 66 | game:BindToClose(bind_to_close) 67 | end 68 | 69 | local function give_item(data: PlayerData, item: string, n: number?) 70 | local amount = n or 1 71 | local current = data.items[item] or 0 72 | 73 | data.items[item] = current + amount 74 | 75 | return data 76 | end 77 | 78 | local function remove_item(data: PlayerData, item: string, n: number?) 79 | local amount = n or 1 80 | local current = data.items[item] or 0 81 | 82 | data.items[item] = current - amount 83 | 84 | return data 85 | end 86 | 87 | player_data_store:transaction("give_item", give_item) 88 | player_data_store:transaction("remove_item", remove_item) 89 | 90 | return { 91 | player_data = player_data, 92 | sessions = sessions, 93 | actions = { 94 | give_item = give_item, 95 | remove_item = remove_item 96 | } 97 | } -------------------------------------------------------------------------------- /docs/api/session.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # Session 7 | 8 | Sessions can be obtained from a [Store](./store.md) and represent a key that is actively being loaded by the server. 9 | 10 | ## Methods 11 | 12 | ### patch 13 | 14 | Patches the currently stored data with a given patcher function. Patches will apply immediately outside of a transaction. 15 | 16 | - Type 17 | 18 | ```luau 19 | function session:patch(fn: (T, U...) -> T, T...) 20 | ``` 21 | 22 | - Details 23 | 24 | When inside a transaction, instead of the patch being applied immediately they are deferred until the next pull. 25 | 26 | ### updated 27 | 28 | Binds a callback to whenever a session is updated. Sessions should serve as a source of truth for your game, so inside the callback you should reconcile all your data into your game state. 29 | 30 | - Type 31 | 32 | ```luau 33 | function session:updated(fn: (T) -> ()) 34 | ``` 35 | 36 | - Details 37 | 38 | The callback is always called immediately for the first time. 39 | 40 | ### stop 41 | 42 | Stops the session and saves the last data stored. 43 | 44 | - Type 45 | 46 | ```luau 47 | function session:stop() 48 | ``` 49 | 50 | ### userid 51 | 52 | Adds a User Id to an array, which will be used for tracking GDPR metadata. 53 | 54 | - Type 55 | 56 | ```luau 57 | function session:userid(user: number) 58 | ``` 59 | 60 | - Details 61 | 62 | This isn't saved. You'll have to call this on every server you join. 63 | 64 | ### await 65 | 66 | Waits until the next request has completed. Use this along with `:stop()` to listen when the store has succesfully saved, or when loading to determine when the data finished loading exactly. 67 | 68 | - Type 69 | 70 | ```luau 71 | function session:await() 72 | ``` 73 | 74 | ### bad 75 | 76 | Returns a reason why data is considered bad. It's recommended to check this frequently, as data can be considered bad when skilift sees a migration that doesn't exist, or an action on a transaction it can't recognize. 77 | 78 | - Type 79 | 80 | ```luau 81 | function session:bad(): string? 82 | ``` 83 | 84 | - Details 85 | 86 | When skilift encounters a session it determines to be "bad", it automatically calls `:stop()` on that session. This is okay, since sessions considered bad should never be edited by the server. It's recommended to kick the player when it's determined that their data is bad. 87 | 88 | ### loaded 89 | 90 | Returns a boolean indicating if the player data has loaded. It's recommended to limit the interactions a player can perform while their data hasn't loaded. 91 | 92 | - Type 93 | 94 | ```luau 95 | function session:loaded(): boolean 96 | 97 | ## Properties 98 | 99 | ### key 100 | 101 | The key the session is reading from. 102 | 103 | - Type 104 | 105 | ```luau 106 | session.key: string 107 | ``` 108 | -------------------------------------------------------------------------------- /docs/resources/advanced-concepts/model.md: -------------------------------------------------------------------------------- 1 | --- 2 | previous: false 3 | next: false 4 | --- 5 | 6 | # Model 7 | 8 | Skilift operates on a different model compared to other Datastore libraries. This model allows skilift to have better safety features compared to other libraries. The model is based around the writings of [Jack's very cool blog post](https://blog.jmdev.space/posts/datastores/). 9 | 10 | ## Session Locking 11 | 12 | Session locking has for a while been the golden standard when it comes to managing data safely. It works well for cases where the library does not work as a source of truth, and is only used to read the initial data. While session locks prevents issues like new player data being overwritten by old player data, it comes with a couple flaws that need to be overcome, primarily: 13 | 14 | - Session locks prevent different servers from editing data at the same time 15 | - Players may be locked out of their data when the last server they were in has crashed, preventing players from loading their data. 16 | - Session locks cause complexity on how to handle data when loading (stealing) 17 | 18 | ## The Model 19 | 20 | Skilift operates on a different model that doesn't require session locks. This works because skilift is the source of truth for your save data. Furthermore, you are required to use actions in order to update. 21 | 22 | ### Source of truth 23 | 24 | Skilift operates as a source of truth for your save data. It frequently pulls from DataStoreService new data, which is then used in your game. The data that is pulled will then receive all the actions you applied on the key since last pull and that will be reconciled into your game with `:updated`. 25 | 26 | This allows other servers to update player data at the same time, without any issues. The data stored in the database will receive all the actions that have been applied since the last pull, which will then be saved again. 27 | 28 | The final part of the equation to making it a source of truth is the API, which is built so that the only way to read and update the data is through `:updated` and `:patch`. Since you can only receive new data through `:updated`, it's encouraged to write code that constantly reconciles the new data into your game state, and because the tables sent from `:updated` are frozen, the only way to write new data to the session is through `:patch`. 29 | 30 | ### Atomic operations 31 | 32 | You are only allowed to use atomic operations to update your data. Each operation is recorded until they can be applied the next time your data is pulled. 33 | 34 | The data in the database doesn't have all the actions applied that we have. When data is pulled, the actions are applied onto the pulled data so that we have new accurate data, which is then pushed back onto the database. 35 | 36 | ### What this means for you 37 | 38 | The way how you design your datastore code is going to be different than normal. You'll need to operate on the data directly, rather than being able to operate on your game state and push the game state into the session. This comes with the benefit though of being able to freely edit keys in multiple servers. 39 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs' 3 | 4 | // https://vitepress.dev/reference/site-config 5 | export default defineConfig({ 6 | 7 | title: "skilift", 8 | titleTemplate: ":title - Datastore library", 9 | description: "Datastore library without any session locking and with transactions!", 10 | base: "/skilift/", 11 | head: [ 12 | ["link", { rel: "icon", href: "/skilift/logo.svg" }], 13 | ["link", { rel: "preconnect", href: "https://fonts.googleapis.com" }], 14 | ["link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossorgin: '' }], 15 | ["link", { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap" }] 16 | ], 17 | cleanUrls: true, 18 | 19 | vite: { 20 | ssr: { 21 | noExternal: [ 22 | '@nolebase/vitepress-plugin-highlight-targeted-heading' 23 | ] 24 | } 25 | }, 26 | 27 | markdown: { 28 | config(md) { 29 | md.use(tabsMarkdownPlugin); 30 | } 31 | }, 32 | 33 | themeConfig: { 34 | logo: "/skilift.svg", 35 | 36 | search: { 37 | provider: "local" 38 | }, 39 | 40 | footer: { 41 | message: 'Released under the MIT License.', 42 | }, 43 | 44 | // https://vitepress.dev/reference/default-theme-config 45 | nav: [ 46 | { text: "Home", link: "/" }, 47 | { text: "Resources", link: "/resources/getting-started/1-why/" }, 48 | { text: "API", link: "/api/skilift/"}, 49 | ], 50 | 51 | sidebar: { 52 | "/api/": [ 53 | { text: "skilift", link: "/api/skilift/" }, 54 | { text: "store", link: "/api/store/" }, 55 | { text: "session", link: "/api/session/" }, 56 | { text: "view", link: "/api/view/" }, 57 | ], 58 | "/resources/": [ 59 | { text: "Getting Started", collapsed: false, items: [ 60 | { text: "Why", link: "/resources/getting-started/1-why/" }, 61 | { text: "Installation", link: "/resources/getting-started/2-installation/" }, 62 | { text: "Setting Up", link: "/resources/getting-started/3-setting-up/" }, 63 | ] }, 64 | { text: "Concepts", collapsed: false, items: [ 65 | { text: "Transactions", link: "/resources/advanced-concepts/transactions/" }, 66 | { text: "Migrations", link: "/resources/advanced-concepts/migrations/" }, 67 | { text: "Strict Mode", link: "/resources/advanced-concepts/strict-mode/" }, 68 | { text: "Model", link: "/resources/advanced-concepts/model/" } 69 | ] }, 70 | ] 71 | }, 72 | 73 | socialLinks: [ 74 | { icon: "github", link: "https://github.com/alicesaidhi/skilift" } 75 | ] 76 | } 77 | }) -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | @import "../../node_modules/vitepress-theme-api/dist/style.css"; 2 | 3 | /** 4 | * Customize default theme styling by overriding CSS variables: 5 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css 6 | */ 7 | /** 8 | * Colors 9 | * -------------------------------------------------------------------------- */ 10 | :root { 11 | --vp-c-brand: #c7693a; 12 | --vp-c-brand-light: #d2a58f; 13 | --vp-c-brand-lighter: #d6a892; 14 | --vp-c-brand-lightest: #e1b39d; 15 | --vp-c-brand-dark: #c79b85; 16 | --vp-c-brand-darker: #ca9175; 17 | --vp-c-brand-dimm: rgba(100, 108, 255, 0.08); 18 | --vp-font-family-mono: "JetBrains Mono" !important; 19 | } 20 | 21 | /** 22 | * Component: Button 23 | * -------------------------------------------------------------------------- */ 24 | 25 | :root { 26 | --vp-button-brand-border: var(--vp-c-brand-light); 27 | --vp-button-brand-text: var(--vp-c-white); 28 | --vp-button-brand-bg: var(--vp-c-brand); 29 | --vp-button-brand-hover-border: var(--vp-c-brand-light); 30 | --vp-button-brand-hover-text: var(--vp-c-white); 31 | --vp-button-brand-hover-bg: var(--vp-c-brand-light); 32 | --vp-button-brand-active-border: var(--vp-c-brand-light); 33 | --vp-button-brand-active-text: var(--vp-c-white); 34 | --vp-button-brand-active-bg: var(--vp-button-brand-bg); 35 | } 36 | 37 | /** 38 | * Component: Home 39 | * -------------------------------------------------------------------------- */ 40 | 41 | :root { 42 | --vp-home-hero-name-color: transparent; 43 | --vp-home-hero-name-background: -webkit-linear-gradient( 44 | 120deg, 45 | #bd34fe 30%, 46 | #41d1ff 47 | ); 48 | 49 | --vp-home-hero-image-background-image: linear-gradient( 50 | -45deg, 51 | #bd34fe 50%, 52 | #47caff 50% 53 | ); 54 | --vp-home-hero-image-filter: blur(40px); 55 | } 56 | 57 | @media (min-width: 640px) { 58 | :root { 59 | --vp-home-hero-image-filter: blur(56px); 60 | } 61 | } 62 | 63 | @media (min-width: 960px) { 64 | :root { 65 | --vp-home-hero-image-filter: blur(72px); 66 | } 67 | } 68 | 69 | /** 70 | * Component: Custom Block 71 | * -------------------------------------------------------------------------- */ 72 | 73 | :root { 74 | --vp-custom-block-tip-border: var(--vp-c-brand); 75 | --vp-custom-block-tip-text: var(--vp-c-brand-darker); 76 | --vp-custom-block-tip-bg: var(--vp-c-brand-dimm); 77 | } 78 | 79 | .dark { 80 | --vp-custom-block-tip-border: var(--vp-c-brand); 81 | --vp-custom-block-tip-text: var(--vp-c-brand-lightest); 82 | --vp-custom-block-tip-bg: var(--vp-c-brand-dimm); 83 | } 84 | 85 | /** 86 | * Component: Algolia 87 | * -------------------------------------------------------------------------- */ 88 | 89 | .DocSearch { 90 | --docsearch-primary-color: var(--vp-c-brand) !important; 91 | } 92 | 93 | /** 94 | * Component VPDoc 95 | * -------------------------------------------------------------------------- */ 96 | .content-container { 97 | max-width: 100% !important; 98 | } 99 | 100 | /** 101 | * Component Grouping 102 | * -------------------------------------------------------------------------- */ 103 | .flex { 104 | display: flex; 105 | gap: 16px; 106 | flex-flow: wrap; 107 | } 108 | 109 | .grouping { 110 | background-color: var(--vp-c-gray-soft); 111 | padding: 2rem 2rem; 112 | border-radius: 1em; 113 | flex: 1 1 0px 114 | } 115 | 116 | .grouping > ul { 117 | list-style-type: square !important; 118 | } 119 | 120 | .grouping > h2 { 121 | margin: 0px !important; 122 | border-top: 0px !important; 123 | padding-top: 0px !important; 124 | } 125 | 126 | .grouping > ul > li > a { 127 | text-decoration: none !important; 128 | } -------------------------------------------------------------------------------- /example/src/server/trade.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local skilift = require(ReplicatedStorage.packages.skilift) 4 | local sessions = require(script.Parent.sessions) 5 | local net = require(ReplicatedStorage.shared.net) 6 | 7 | type TradingInfo = { 8 | a: Player, 9 | b: Player, 10 | 11 | offer_a: { [string]: number }, 12 | offer_b: { [string]: number }, 13 | 14 | a_ready: boolean, 15 | b_ready: boolean 16 | } 17 | 18 | local trading_requested = {} 19 | local trading_info: {[Player]: TradingInfo} = {} 20 | 21 | local function start_trade(a: Player, b: Player) 22 | trading_requested[a] = nil 23 | trading_requested[b] = nil 24 | 25 | local info = { 26 | a = a, 27 | b = b, 28 | 29 | offer_a = {}, 30 | offer_b = {}, 31 | 32 | a_ready = false, 33 | b_ready = false, 34 | } 35 | 36 | trading_info[a] = info 37 | trading_info[b] = info 38 | 39 | net.start_trade.fire(a, { who = b }) 40 | net.start_trade.fire(b, { who = a }) 41 | end 42 | 43 | local function set_offer(player: Player, item: string, amount: number) 44 | local info = trading_info[player] 45 | if not info then return end 46 | local offer = if info.a == player then info.offer_a else info.offer_b 47 | local other = if info.a == player then info.b else info.a 48 | 49 | local n = sessions.player_data[player].items[item] or -1 50 | if amount > n then return end 51 | if n <= 0 then return end 52 | 53 | offer[item] = amount 54 | 55 | print("firing", other) 56 | net.set_item_offer.fire(other, { item = item, amount = amount }) 57 | end 58 | 59 | local function finish_trade(info: TradingInfo) 60 | 61 | local a = sessions.sessions[info.a] 62 | local b = sessions.sessions[info.b] 63 | 64 | for key, value in info.offer_a do 65 | a:patch(sessions.actions.remove_item, key, value) 66 | end 67 | for key, value in info.offer_b do 68 | b:patch(sessions.actions.remove_item, key, value) 69 | end 70 | skilift.transaction( 71 | function() 72 | for key, value in info.offer_a do 73 | b:patch(sessions.actions.give_item, key, value) 74 | end 75 | for key, value in info.offer_b do 76 | a:patch(sessions.actions.give_item, key, value) 77 | end 78 | end, 79 | function() 80 | for key, value in info.offer_a do 81 | a:patch(sessions.actions.give_item, key, value) 82 | end 83 | for key, value in info.offer_b do 84 | b:patch(sessions.actions.give_item, key, value) 85 | end 86 | end 87 | ) 88 | 89 | trading_info[info.a] = nil 90 | trading_info[info.b] = nil 91 | 92 | net.trade_rejected.fire(info.a) 93 | net.trade_rejected.fire(info.b) 94 | end 95 | 96 | net.set_item.set_callback(function(from, data) 97 | set_offer(from, data.item, data.amount) 98 | end) 99 | 100 | net.request_trade.set_callback(function(from, to) 101 | if trading_info[from] then return end 102 | if trading_requested[to] == from then return start_trade(from, to) end 103 | 104 | trading_requested[from] = to 105 | return 106 | end) 107 | 108 | net.reject_trade.set_callback(function(from) 109 | local data = trading_info[from] 110 | 111 | if not data then return end 112 | 113 | trading_info[data.a] = nil 114 | trading_info[data.b] = nil 115 | 116 | net.trade_rejected.fire(data.a) 117 | net.trade_rejected.fire(data.b) 118 | end) 119 | 120 | net.trade_ready.set_callback(function(from) 121 | local data = trading_info[from] 122 | 123 | if not data then return end 124 | 125 | if data.a == from then 126 | data.a_ready = true 127 | else 128 | data.b_ready = true 129 | end 130 | 131 | if data.a_ready and data.b_ready then 132 | finish_trade(data) 133 | end 134 | end) 135 | 136 | return { 137 | start_trade = start_trade, 138 | } -------------------------------------------------------------------------------- /src/transaction.luau: -------------------------------------------------------------------------------- 1 | local flags = require("./flags") 2 | local guid = require("./guid") 3 | local flags = require("./flags") 4 | local types = require("./types") 5 | local Session = require("./session") 6 | local budget = require("./budget") 7 | local LOG = require("./log") 8 | local task = require("./task") 9 | local actions = require("./actions") 10 | local checktransaction = require("./checktransaction") 11 | 12 | local TRANSACTION_NAME = "_$SKILIFT_TRANSACTION" 13 | local TRANSACTION_SCOPE = "global" 14 | 15 | type TransactionInfo = types.TransactionInfo 16 | type TransactionId = types.TransactionId 17 | 18 | local function create_transaction(success: (() -> ()), failure: (() -> ())?) 19 | local TRANSACTION_STORE = flags.datastoreservice:GetDataStore(TRANSACTION_NAME, TRANSACTION_SCOPE) 20 | 21 | local uuid = guid() 22 | LOG(`STARTING TRANSACTION ID {uuid}`) 23 | local transaction_info: TransactionInfo = { 24 | uuid = uuid, 25 | state = "success", 26 | sessions = {} 27 | } 28 | 29 | LOG("GETTING ACTIONS FOR SUCCESS") 30 | do 31 | transaction_info.state = "success" 32 | flags.processing_transaction = transaction_info 33 | local thread = coroutine.create(success) 34 | local ok, err = coroutine.resume(thread) 35 | flags.processing_transaction = false 36 | 37 | if not ok then error(err) end 38 | end 39 | LOG("GETTING ACTIONS FOR FAILURE") 40 | 41 | if failure then 42 | transaction_info.state = "failure" 43 | flags.processing_transaction = transaction_info 44 | local thread = coroutine.create(failure) 45 | local ok, err = coroutine.resume(thread) 46 | flags.processing_transaction = false 47 | 48 | if not ok then error(err) end 49 | end 50 | 51 | local total = 0 52 | 53 | for _ in transaction_info.sessions do 54 | total += 1 55 | end 56 | 57 | if total == 0 then return true end 58 | 59 | LOG(`{uuid}: WAITING FOR {total + 6} UPDATE BUDGET FOR {total} SESSIONS, CURRENT: {budget.get_budget("update")}`) 60 | budget.delay_until_meets_budget("update", total + 6) 61 | 62 | -- each session needs to be manually pulled 63 | local success, failed = 0, 0 64 | for session, info in transaction_info.sessions do 65 | LOG(`{uuid}: Pulling {session._name}`) 66 | task.spawn(function() 67 | local ok = pcall(Session.pull_auto, session, { [uuid] = info }) 68 | LOG(`{uuid}: STATUS {if ok then "SUCCESS" else "FAIL"}`) 69 | if ok == false then 70 | failed += 1 71 | end 72 | if ok == true then success += 1 end 73 | end) 74 | end 75 | 76 | while success < total and failed == 0 do 77 | task.wait() -- yield until all threads finished 78 | end 79 | LOG(`{uuid}: TOTAL {total}, SUCCESS {success}, FAIL {failed}`) 80 | 81 | if success == total then 82 | -- everything has been saved, we can now mark the transaction as finished 83 | local ok, err = pcall(function() 84 | budget.delay_until_meets_budget("set", 1) 85 | TRANSACTION_STORE:SetAsync(uuid, "finished") 86 | end) 87 | LOG(`{uuid}: WRITTEN TO KEY STATUS {if ok then "SUCCESS" else "FAIL"}`) 88 | 89 | checktransaction.set_transaction_status(uuid, ok) 90 | 91 | for session, info in transaction_info.sessions do 92 | LOG(`{uuid}: Pulling {session._name}`) 93 | for _, action in (if ok == true then info.success else info.failure) do 94 | local fn = actions.obtain_transaction_action(session._.store, action.fn) 95 | Session.commit_transaction_early(session, fn, unpack(action.values)) 96 | end 97 | end 98 | 99 | return ok 100 | else 101 | checktransaction.set_transaction_status(uuid, false) 102 | -- now that the transaction is done, we can pull all data again 103 | for session, info in transaction_info.sessions do 104 | LOG(`{uuid}: Pulling {session._name}`) 105 | for _, action in info.failure do 106 | local fn = actions.obtain_transaction_action(session._.store, action.fn) 107 | Session.commit_transaction_early(session, fn, unpack(action.values)) 108 | end 109 | end 110 | 111 | return false 112 | end 113 | end 114 | 115 | return { 116 | create_transaction = create_transaction 117 | 118 | } -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // declare class DataStoreKeyInfo { 2 | // CreatedTime: number 3 | // UpdatedTime: number 4 | // Version: string 5 | 6 | // GetMetadata(this: DataStoreKeyInfo): Map 7 | // GetUserIds(this: DataStoreKeyInfo): Array 8 | // } 9 | 10 | // declare class DataStore { 11 | // GetVersionAsync(key: string, version: string): LuaTuple<[unknown, DataStoreKeyInfo, ]> 12 | // } 13 | 14 | // declare interface DataStoreService { 15 | // GetDataStore(this: DataStoreService, name: string, scope?: string): DataStore 16 | // } 17 | 18 | type Migration = { step: string, migrate: (old: unknown) => unknown } 19 | export type Migrations = Migration[] 20 | 21 | export type Action = (old: T, ...params: U) => T | undefined 22 | 23 | export interface StoreOptions { 24 | /** 25 | * The name of the Data Store to retrieve. 26 | */ 27 | name: string, 28 | /** 29 | * The scope of the Data Store to retrieve. Default "global" 30 | */ 31 | scope?: string, 32 | /** 33 | * Called whenever a session is loaded without data - this should represent 34 | * the latest format currently available. 35 | * @returns DefaultData 36 | */ 37 | default_data: () => DefaultData, 38 | /** 39 | * A list of migrations and the associated steps needed to get to them. 40 | * They are performed in the order specified. 41 | */ 42 | migrations: Migrations, 43 | /** 44 | * Called whenever skilift receives data. This checks if the data matches a 45 | * specific schema. 46 | * @param unknown 47 | * @returns 48 | */ 49 | schema?: (value: unknown) => boolean, 50 | /** 51 | * A custom specified delay on when data is auto-saved. 52 | */ 53 | pull_delay?: number 54 | 55 | } 56 | 57 | export declare class Session { 58 | /** 59 | * Patches the currently stored data with a given patcher function. 60 | * Patches will apply immediately outside of a transaction. 61 | * @param this 62 | * @param fn 63 | * @param params 64 | */ 65 | patch(this: Session, fn: Action, ...params: U): boolean 66 | /** 67 | * Binds a callback to whenever a session is updated. Sessions should serve 68 | * as a source of truth for your game, so inside the callback you should 69 | * reconcile all your data into your game state. 70 | * 71 | * @param this 72 | * @param fn 73 | */ 74 | updated(this: Session, fn: (value: T) => void): void 75 | /** 76 | * Stops the session and saves the last data stored. 77 | * @param this 78 | */ 79 | stop(this: Session): void 80 | /** 81 | * Waits until the next request has completed. Use this alongside `:stop()` 82 | * to listen when the store has succesfully saved, or when loading to determine 83 | * when the data finished loading exactly. 84 | * @param this 85 | */ 86 | await(this: Session): void 87 | /** 88 | * Returns a reason why data is considered bad if there is one. It's recommended 89 | * to check this frequently. When skilift encounters bad data, it will automatically 90 | * stop that session to prevent the developer from saving said data. It's 91 | * recommended to kick the player when they have bad data. 92 | */ 93 | bad(this: Session): string | undefined 94 | /** 95 | * Returns a boolean indicating if the player data has loaded. It's 96 | * recommended to limit the interactions a player can perform while 97 | * their data hasn't loaded. 98 | * @param this 99 | */ 100 | loaded(this: Session): boolean 101 | } 102 | 103 | export class View { 104 | latest(this: View): T 105 | version(this: View, version: string): T 106 | timestamp(this: View, unix: number): T 107 | 108 | } 109 | 110 | export class Store { 111 | /** 112 | * Loads a key and returns a new session actively pulling that key. 113 | * @param this 114 | * @param key 115 | */ 116 | public load(this: Store, key: string): Session 117 | /** 118 | * Views a key and returns an object to read the latest version of that key 119 | * or the value of the key in the past. 120 | * @param this 121 | * @param key 122 | */ 123 | public view(this: Store, key: string): View 124 | /** 125 | * Registers the given function as an action, allowing it to be used for 126 | * patching data. 127 | * @param this 128 | * @param action 129 | */ 130 | public action(this: Store, action: Action): Action 131 | /** 132 | * Registers the given function as an action, allowing it to be used for 133 | * patching within a transaction. These actions are deferred and not applied 134 | * immediately. Unlike regular actions, these actions must be provided with 135 | * a **unique** name. 136 | */ 137 | public transaction(this: Store, name: string, action: Action): Action 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/util.luau: -------------------------------------------------------------------------------- 1 | local function clone(x: T): T 2 | if type(x) == "table" then 3 | local t = table.clone(x) 4 | 5 | for k, v in t do 6 | if typeof(k) == "table" or typeof(v) == "table" then 7 | t[clone(k)] = clone(v) 8 | end 9 | end 10 | 11 | return t 12 | else 13 | return x 14 | end 15 | end 16 | 17 | local function deep_freeze(x: T) 18 | if type(x) == "table" then 19 | if not table.isfrozen(x) then table.freeze(x) end 20 | for i, v in x do 21 | deep_freeze(i) 22 | deep_freeze(v) 23 | end 24 | end 25 | 26 | return x 27 | end 28 | 29 | type JSONErrorKind = 30 | | "non_real_number" 31 | | "bad_type" 32 | | "only_use_string_key_dict" 33 | | "recursive_table" 34 | | "invalid_utf8" 35 | | "saving_nil" 36 | export type JSONError = { 37 | why: JSONErrorKind, 38 | where: { string | number } 39 | } 40 | 41 | local function path(where: { string | number }) 42 | local t = {} 43 | for i = #where, 1, -1 do 44 | table.insert(t, where[i]) 45 | end 46 | return table.concat(t, ".") 47 | end 48 | 49 | local function tostring_err(self: JSONError) 50 | return if self.why == "bad_type" then `value at {path(self.where)} isn't JSON-encodeable` 51 | elseif self.why == "non_real_number" then `value at {path(self.where)} is not a real number` 52 | elseif self.why == "only_use_string_key_dict" then `value at {path(self.where)} is a dictionary, but not all keys are strings` 53 | elseif self.why == "recursive_table" then `value at {path(self.where)} is recursive.` 54 | elseif self.why == "invalid_utf8" then `value at {path(self.where)} contains invalid utf-8 characters.` 55 | elseif self.why == "saving_nil" then `attempt to save nil at {path(self.where)} - use false instead` 56 | else `unknown error at {path(self.where)}` 57 | end 58 | 59 | local function throw(why: JSONErrorKind, ...: string | number): never 60 | error(setmetatable({ why = why, where = { ... } }, { __tostring = tostring_err })) 61 | end 62 | 63 | local function check_table_type(x: { [string | number]: any }): "dict" | "array" | "bad" | "none" 64 | for k in x do 65 | if typeof(k) == "number" and k == 1 then 66 | return "array" 67 | elseif typeof(k) == "string" then 68 | return "dict" 69 | else 70 | return "bad" 71 | end 72 | end 73 | return "none" 74 | end 75 | 76 | local references = setmetatable({}, {__mode = "ks"}) 77 | local function check_json(x: {}, ...: string | number): boolean 78 | if typeof(x) == "string" then 79 | local result = utf8.len(x) 80 | if result == nil then throw("invalid_utf8", ...) end 81 | return true 82 | elseif typeof(x) == "number" then 83 | return if x ~= x then throw("non_real_number", ...) 84 | elseif math.abs(x) == math.huge then throw("non_real_number", ...) 85 | else true 86 | elseif typeof(x) == "buffer" then 87 | return true 88 | elseif typeof(x) == "boolean" then 89 | return true 90 | elseif typeof(x) == "table" then 91 | if references[x] then throw("recursive_table", ...) end 92 | references[x] = true 93 | local kind: "array" | "bad" | "dict" | "none" = check_table_type(x) 94 | 95 | if kind == "array" then 96 | for key, value in x do 97 | check_json(value, key, ...) 98 | end 99 | elseif kind == "dict" then 100 | for key, value in x do 101 | if typeof(key) ~= "string" then throw("only_use_string_key_dict", ...) end 102 | check_json(value, key, ...) 103 | end 104 | elseif kind == "bad" then 105 | throw("only_use_string_key_dict", ...) 106 | end 107 | 108 | return true 109 | elseif typeof(x) == "nil" then 110 | return throw("saving_nil") 111 | else 112 | return throw("bad_type", ...) 113 | end 114 | end 115 | 116 | local function check_json_valid(x: {}) 117 | table.clear(references) 118 | return check_json(x, "root") 119 | end 120 | 121 | local function delta_eq(a: T, b: T, ...: string): (boolean, string) 122 | if typeof(a) ~= typeof(b) then 123 | return false, table.concat({ ... }, ".") 124 | elseif typeof(a) == "buffer" and typeof(b) == "buffer" then 125 | return buffer.tostring(a) == buffer.tostring(b) 126 | elseif typeof(a) == "table" and typeof(b) == "table" then 127 | if #a ~= #b then return false, table.concat({ ... }, ".") end 128 | 129 | for key, value in a do 130 | local ok, path = delta_eq(value, b[key], key, ...) 131 | if not ok then return ok, path end 132 | end 133 | 134 | for key, value in b do 135 | if a[key] ~= nil then continue end 136 | local ok, path = delta_eq(value, a[key], key, ...) 137 | if not ok then return ok, path end 138 | end 139 | 140 | return true 141 | else 142 | return a == b 143 | end 144 | end 145 | 146 | return { 147 | clone = clone, 148 | deep_freeze = deep_freeze, 149 | check_json_valid = check_json_valid, 150 | delta_eq = delta_eq 151 | } -------------------------------------------------------------------------------- /src/types.luau: -------------------------------------------------------------------------------- 1 | export type Action = (old: T, U...) -> T | nil 2 | export type JSON = string | number | boolean | buffer | { JSON } | { [string]: JSON } 3 | 4 | export type WhyBad = 5 | --- A transaction used on the session cannot be recognized. This is likely 6 | --- because the server is outdated, or developer error. 7 | | "unrecognized_transaction" 8 | --- The data cannot be loaded. This is likely because you're trying to load a 9 | --- key that doesn't match the expected data format. 10 | | "cannot_load" 11 | --- The data has a migration that is unknown to this server. This is likely 12 | --- because the server is outdated, or developer error. 13 | | "unknown_migration" 14 | 15 | export type StoreOptions = { 16 | --- The name of the Data Store 17 | name: string, 18 | --- The scope of the Data Store 19 | scope: string?, 20 | --- The default data that will automatically be available. 21 | default_data: () -> T, 22 | --- Performs migrations in the given order. The version number is associated 23 | --- with the index. It's important not to change any of the migrations. 24 | migrations: { {step: string, migrate: (any) -> (any)} }, 25 | --- Allows you to change the delay before a session pulls data again. 26 | pull_delay: number?, 27 | --- A schema that would be validated against every patch 28 | schema: ((unknown) -> boolean)?, 29 | } 30 | 31 | export type Store = { 32 | _store: DataStore, 33 | _options: StoreOptions, 34 | _allowed_actions: { [(T) -> T]: true }, 35 | _transaction_actions: { [(T) -> T]: string }, 36 | _transaction_names: { [string]: (T) -> T }, 37 | 38 | --- Action 39 | action: (Store, action: Action) -> Action, 40 | transaction: (Store, name: string, action: Action) -> Action, 41 | --- Starts a new safe session that frequently polls for updates. 42 | load: (Store, key: string) -> Session, 43 | --- Returns a object used to gain insight into a key without polling for new 44 | --- data and applying updates. 45 | view: (Store, key: string) -> View 46 | } 47 | 48 | export type View = { 49 | --- Pulls for the latest version and returns an immutable copy 50 | latest: (View) -> T?, 51 | --- Pulls for a version at a given time. 52 | timestamp: (View, at: number) -> T?, 53 | --- Pulls for a vesrion with the given key. 54 | version: (View, version: string) -> T?, 55 | } 56 | 57 | export type Connection = { 58 | wait: (Connection) -> () 59 | } 60 | 61 | export type Session = { 62 | _: { 63 | store: Store, 64 | datastore: DataStore, 65 | options: StoreOptions, 66 | --- A list of changes that haven't been pushed yet. 67 | changes: { Action }, 68 | --- When the next refresh happens. 69 | next_save_opportunity: number, 70 | --- Runs to reconcile the Datastores into the data. 71 | updated: (T) -> (), 72 | --- The currently stored data. 73 | cached: StoredData, 74 | --- The thread responsible for autosaving. 75 | thread: thread, 76 | --- Threads waiting to resume 77 | waiting_for_saved: { () -> () }, 78 | --- A reason why the session is not loaded properly 79 | why_bad: string?, 80 | --- User IDs for GDPR 81 | user_ids: { number }, 82 | --- Indicates if a request has succeeded 83 | first_request_succeeded: boolean, 84 | --- The delay between a pull 85 | pull_delay: number, 86 | --- Indicates the state it's currently in. 87 | finished: false | "saving" | "done", 88 | }, 89 | 90 | name: string, 91 | key: string, 92 | 93 | --- Patches with the given update function. Fails if the data is locked. 94 | patch: (Session, fn: Action, U...) -> boolean, 95 | --- Binds a function to whenever the session receives updated data. 96 | --- This should be reconciled into your own game state. When called, it will 97 | --- run the function given immediately. 98 | updated: (Session, (T) -> ()) -> (), 99 | --- Stops polling the datastore for data, and disables any patching. This 100 | --- performs one more save. Releases the lock if it was locked. 101 | stop: (Session) -> (), 102 | --- Adds a user id to the gdpr list. 103 | userid: (Session, user_id: number) -> (), 104 | --- Returns if the state has loaded 105 | loaded: (Session) -> boolean, 106 | --- Returns an error code if the session is considered "bad". 107 | bad: (Session) -> WhyBad?, 108 | --- Yields until the next pull happens 109 | await: (Session) -> (), 110 | 111 | -- ---- SESSION LOCKING ADDENDUM ---- 112 | -- --- Locks data. This should only be used in cases where session locking is 113 | -- --- required. When sesion locking, other servers are unable to edit the data. 114 | -- --- This may error. 115 | -- lock: (Session, expires: number) -> (), 116 | -- --- Steals a session from another server (if necessary) 117 | -- steal: (Session) -> (), 118 | -- --- Unlocks the session, allowing other servers to edit the data. 119 | -- unlock: (Session) -> (), 120 | } 121 | 122 | export type TransactionId = string 123 | 124 | export type TransactionInfo = { 125 | uuid: TransactionId, 126 | state: "success" | "failure", 127 | sessions: { [Session]: TransactionData }, 128 | } 129 | 130 | export type TransactionAction = { fn: string, values: { JSON } } 131 | 132 | --- Data stored in the key, containing information about transactions that 133 | --- still need to be performed 134 | export type TransactionData = { 135 | success: { TransactionAction }, 136 | failure: { TransactionAction } 137 | } 138 | 139 | export type StoredData = { 140 | pending_transactions: { [TransactionId]: TransactionData }, 141 | migrations_performed: { string }, 142 | data: T 143 | } 144 | 145 | return {} -------------------------------------------------------------------------------- /example/src/client/ui/components/hud.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | 4 | local background = require(script.Parent.background) 5 | local effects = require(ReplicatedStorage.client.ui.effects) 6 | local state = require(ReplicatedStorage.client.ui.state) 7 | local vide = require(ReplicatedStorage.packages.vide) 8 | local button = require(script.Parent.button) 9 | local screengui = require(script.Parent.screengui) 10 | local text = require(script.Parent.text) 11 | 12 | local create = vide.create 13 | local indexes = vide.indexes 14 | local values = vide.values 15 | 16 | local function hud() 17 | 18 | return screengui { 19 | name = "HUD", 20 | enabled = true, 21 | 22 | screengui { 23 | name = "Inventory", 24 | enabled = true, 25 | 26 | background { 27 | size = UDim2.fromOffset(0, 0), 28 | position = UDim2.fromScale(1, 1), 29 | anchor_point = Vector2.new(1, 1), 30 | automatic_size = Enum.AutomaticSize.XY, 31 | 32 | create "UIListLayout" { 33 | VerticalAlignment = Enum.VerticalAlignment.Bottom, 34 | HorizontalAlignment = Enum.HorizontalAlignment.Right, 35 | }, 36 | 37 | indexes(state.inventory, function(value, key) 38 | return background { 39 | size = UDim2.fromOffset(100, 30), 40 | 41 | text { 42 | text = function() 43 | return `{key}: {value()}x` 44 | end 45 | } 46 | } 47 | end) 48 | } 49 | }, 50 | 51 | screengui { 52 | name = "Unlock", 53 | enabled = function() 54 | return state.obtained() ~= false 55 | end, 56 | 57 | background { 58 | size = UDim2.fromOffset(300, 300), 59 | position = UDim2.fromScale(0.5, 0.5), 60 | anchor_point = Vector2.new(0.5, 0.5), 61 | 62 | create "UIListLayout" { 63 | VerticalAlignment = Enum.VerticalAlignment.Center, 64 | HorizontalAlignment = Enum.HorizontalAlignment.Center, 65 | }, 66 | 67 | text { 68 | text = function() 69 | return `You obtained a {state.obtained()}` 70 | end 71 | }, 72 | 73 | button { 74 | size = UDim2.fromOffset(100, 50), 75 | 76 | activated = function() 77 | state.obtained(false) 78 | end, 79 | 80 | text { text = "Close" } 81 | } 82 | 83 | } 84 | }, 85 | 86 | screengui { 87 | name = "player list", 88 | enabled = function() return not state.trading_with() end, 89 | 90 | background { 91 | automatic_size = Enum.AutomaticSize.Y, 92 | position = UDim2.fromScale(0, 0.5), 93 | 94 | values(state.players, function(player, index) 95 | if player == Players.LocalPlayer then return nil end 96 | return button { 97 | size = UDim2.fromOffset(200, 50), 98 | layout_order = index, 99 | text { 100 | text = `trade {player.Name}` 101 | }, 102 | 103 | activated = function() 104 | effects.start_trade(player) 105 | end 106 | } 107 | end) 108 | } 109 | }, 110 | 111 | screengui { 112 | name = "Trading", 113 | enabled = function() 114 | return state.trading_with() ~= false 115 | end, 116 | 117 | background { 118 | size = UDim2.fromOffset(500, 300), 119 | 120 | create "UIListLayout" { 121 | HorizontalAlignment = Enum.HorizontalAlignment.Center, 122 | VerticalAlignment = Enum.VerticalAlignment.Center, 123 | VerticalFlex = Enum.UIFlexAlignment.SpaceEvenly, 124 | }, 125 | 126 | background { 127 | size = UDim2.new(1, 0, 0, 30), 128 | 129 | text { 130 | text = function() 131 | return `Trading with {(state.trading_with() or {}).Name}` 132 | end 133 | } 134 | }, 135 | 136 | background { 137 | size = UDim2.new(1, 0, 0, 0), 138 | 139 | create "UIFlexItem" { FlexMode = Enum.UIFlexMode.Fill }, 140 | 141 | create "UIListLayout" { 142 | FillDirection = Enum.FillDirection.Horizontal, 143 | HorizontalAlignment = Enum.HorizontalAlignment.Center, 144 | VerticalAlignment = Enum.VerticalAlignment.Center, 145 | HorizontalFlex = Enum.UIFlexAlignment.SpaceEvenly, 146 | Padding = UDim.new(0, 16), 147 | }, 148 | 149 | background { 150 | size = UDim2.fromScale(0.5, 1), 151 | 152 | create "UIListLayout" {}, 153 | 154 | indexes(state.inventory, function(value, name) 155 | local function get_trading() 156 | local offer = state.trading_my_offer() 157 | return offer[name] or 0 158 | end 159 | 160 | return background { 161 | size = UDim2.new(1, 0, 0, 20), 162 | 163 | create "UIListLayout" { 164 | FillDirection = Enum.FillDirection.Horizontal, 165 | HorizontalFlex = Enum.UIFlexAlignment.Fill 166 | }, 167 | 168 | text {text = function() return `{name}: {get_trading()}/{value()}` end}, 169 | 170 | button { 171 | size = UDim2.fromOffset(100, 20), 172 | text { text = "+" }, 173 | activated = function() 174 | print("add", name) 175 | effects.add(name) 176 | end 177 | }, 178 | 179 | button { 180 | size = UDim2.fromOffset(100, 20), 181 | text { text = "-" }, 182 | activated = function() 183 | print("remove", name) 184 | effects.remove(name) 185 | end 186 | }, 187 | 188 | } 189 | end) 190 | 191 | }, 192 | 193 | background { 194 | size = UDim2.fromScale(0.5, 1), 195 | 196 | create "UIListLayout" {}, 197 | 198 | indexes(state.trading_their_offer, function(value, index) 199 | return background { 200 | size = UDim2.new(1, 0, 0, 20), 201 | 202 | text {text = function() return `{index}: {value()}x` end} 203 | } 204 | end) 205 | } 206 | 207 | }, 208 | 209 | background { 210 | size = UDim2.new(1, 0, 0, 50), 211 | 212 | create "UIListLayout" { 213 | FillDirection = Enum.FillDirection.Horizontal, 214 | HorizontalAlignment = Enum.HorizontalAlignment.Center, 215 | VerticalAlignment = Enum.VerticalAlignment.Center, 216 | }, 217 | 218 | button { 219 | text { text = "Finalize" }, 220 | 221 | activated = function() 222 | state.trading_finalized(true) 223 | effects.ready() 224 | end 225 | }, 226 | 227 | button { 228 | text { text = "Reject" }, 229 | 230 | activated = effects.reject_trade 231 | }, 232 | 233 | } 234 | 235 | } 236 | }, 237 | 238 | button { 239 | position = UDim2.fromScale(0.5, 1), 240 | anchor_point = Vector2.new(0, 1), 241 | 242 | activated = effects.roll_item, 243 | 244 | text {text = "roll"} 245 | } 246 | 247 | } 248 | 249 | end 250 | 251 | return hud -------------------------------------------------------------------------------- /example/src/shared/net/client.luau: -------------------------------------------------------------------------------- 1 | --!native 2 | --!optimize 2 3 | --!nocheck 4 | --!nolint 5 | --#selene: allow(unused_variable, incorrect_standard_library_use, global_usage) 6 | -- Client generated by Zap v0.6.16 (https://github.com/red-blox/zap) 7 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 8 | local RunService = game:GetService("RunService") 9 | 10 | local outgoing_buff: buffer 11 | local outgoing_used: number 12 | local outgoing_size: number 13 | local outgoing_inst: { Instance } 14 | local outgoing_apos: number 15 | 16 | local incoming_buff: buffer 17 | local incoming_read: number 18 | local incoming_inst: { Instance } 19 | local incoming_ipos: number 20 | 21 | -- thanks to https://dom.rojo.space/binary.html#cframe 22 | local CFrameSpecialCases = { 23 | CFrame.Angles(0, 0, 0), 24 | CFrame.Angles(math.rad(90), 0, 0), 25 | CFrame.Angles(0, math.rad(180), math.rad(180)), 26 | CFrame.Angles(math.rad(-90), 0, 0), 27 | CFrame.Angles(0, math.rad(180), math.rad(90)), 28 | CFrame.Angles(0, math.rad(90), math.rad(90)), 29 | CFrame.Angles(0, 0, math.rad(90)), 30 | CFrame.Angles(0, math.rad(-90), math.rad(90)), 31 | CFrame.Angles(math.rad(-90), math.rad(-90), 0), 32 | CFrame.Angles(0, math.rad(-90), 0), 33 | CFrame.Angles(math.rad(90), math.rad(-90), 0), 34 | CFrame.Angles(0, math.rad(90), math.rad(180)), 35 | CFrame.Angles(0, math.rad(-90), math.rad(180)), 36 | CFrame.Angles(0, math.rad(180), math.rad(0)), 37 | CFrame.Angles(math.rad(-90), math.rad(-180), math.rad(0)), 38 | CFrame.Angles(0, math.rad(0), math.rad(180)), 39 | CFrame.Angles(math.rad(90), math.rad(180), math.rad(0)), 40 | CFrame.Angles(0, math.rad(0), math.rad(-90)), 41 | CFrame.Angles(0, math.rad(-90), math.rad(-90)), 42 | CFrame.Angles(0, math.rad(-180), math.rad(-90)), 43 | CFrame.Angles(0, math.rad(90), math.rad(-90)), 44 | CFrame.Angles(math.rad(90), math.rad(90), 0), 45 | CFrame.Angles(0, math.rad(90), 0), 46 | CFrame.Angles(math.rad(-90), math.rad(90), 0), 47 | } 48 | 49 | local function alloc(len: number) 50 | if outgoing_used + len > outgoing_size then 51 | while outgoing_used + len > outgoing_size do 52 | outgoing_size = outgoing_size * 2 53 | end 54 | 55 | local new_buff = buffer.create(outgoing_size) 56 | buffer.copy(new_buff, 0, outgoing_buff, 0, outgoing_used) 57 | 58 | outgoing_buff = new_buff 59 | end 60 | 61 | outgoing_apos = outgoing_used 62 | outgoing_used = outgoing_used + len 63 | 64 | return outgoing_apos 65 | end 66 | 67 | local function read(len: number) 68 | local pos = incoming_read 69 | incoming_read = incoming_read + len 70 | 71 | return pos 72 | end 73 | 74 | local function save() 75 | return { 76 | buff = outgoing_buff, 77 | used = outgoing_used, 78 | size = outgoing_size, 79 | inst = outgoing_inst, 80 | } 81 | end 82 | 83 | local function load(data: { 84 | buff: buffer, 85 | used: number, 86 | size: number, 87 | inst: { Instance }, 88 | }) 89 | outgoing_buff = data.buff 90 | outgoing_used = data.used 91 | outgoing_size = data.size 92 | outgoing_inst = data.inst 93 | end 94 | 95 | local function load_empty() 96 | outgoing_buff = buffer.create(64) 97 | outgoing_used = 0 98 | outgoing_size = 64 99 | outgoing_inst = {} 100 | end 101 | 102 | load_empty() 103 | 104 | local types = {} 105 | if not RunService:IsRunning() then 106 | local noop = function() end 107 | return table.freeze({ 108 | send_events = noop, 109 | roll_item = table.freeze({ 110 | fire = noop 111 | }), 112 | request_trade = table.freeze({ 113 | fire = noop 114 | }), 115 | reject_trade = table.freeze({ 116 | fire = noop 117 | }), 118 | set_item = table.freeze({ 119 | fire = noop 120 | }), 121 | trade_ready = table.freeze({ 122 | fire = noop 123 | }), 124 | start_trade = table.freeze({ 125 | set_callback = noop 126 | }), 127 | obtain_item = table.freeze({ 128 | set_callback = noop 129 | }), 130 | trade_rejected = table.freeze({ 131 | set_callback = noop 132 | }), 133 | update_inventory = table.freeze({ 134 | set_callback = noop 135 | }), 136 | set_item_offer = table.freeze({ 137 | set_callback = noop 138 | }), 139 | }) :: Events 140 | end 141 | if RunService:IsServer() then 142 | error("Cannot use the client module on the server!") 143 | end 144 | local remotes = ReplicatedStorage:WaitForChild("ZAP") 145 | local reliable = remotes:WaitForChild("ZAP_RELIABLE") 146 | local unreliable = remotes:WaitForChild("ZAP_UNRELIABLE") 147 | 148 | assert(reliable:IsA("RemoteEvent"), "Expected ZAP_RELIABLE to be a RemoteEvent") 149 | assert(unreliable:IsA("UnreliableRemoteEvent"), "Expected ZAP_UNRELIABLE to be an UnreliableRemoteEvent") 150 | 151 | 152 | local function send_events() 153 | if outgoing_used ~= 0 then 154 | local buff = buffer.create(outgoing_used) 155 | buffer.copy(buff, 0, outgoing_buff, 0, outgoing_used) 156 | 157 | reliable:FireServer(buff, outgoing_inst) 158 | 159 | outgoing_buff = buffer.create(64) 160 | outgoing_used = 0 161 | outgoing_size = 64 162 | table.clear(outgoing_inst) 163 | end 164 | end 165 | 166 | RunService.Heartbeat:Connect(send_events) 167 | 168 | local events = table.create(10) 169 | local event_queue: { [number]: { any } } = table.create(10) 170 | event_queue[6] = {} 171 | event_queue[7] = {} 172 | event_queue[8] = 0 173 | event_queue[9] = {} 174 | event_queue[10] = {} 175 | reliable.OnClientEvent:Connect(function(buff, inst) 176 | incoming_buff = buff 177 | incoming_inst = inst 178 | incoming_read = 0 179 | incoming_ipos = 0 180 | local len = buffer.len(buff) 181 | while incoming_read < len do 182 | local id = buffer.readu8(buff, read(1)) 183 | if id == 6 then 184 | local value 185 | value = {} 186 | incoming_ipos = incoming_ipos + 1 187 | value.who = incoming_inst[incoming_ipos] 188 | assert(value.who ~= nil) 189 | if events[6] then 190 | events[6](value) 191 | else 192 | table.insert(event_queue[6], value) 193 | if #event_queue[6] > 64 then 194 | warn(`[ZAP] {#event_queue[6]} events in queue for start_trade. Did you forget to attach a listener?`) 195 | end 196 | end 197 | elseif id == 7 then 198 | local value 199 | value = {} 200 | local len_1 = buffer.readu16(incoming_buff, read(2)) 201 | value.obtained = buffer.readstring(incoming_buff, read(len_1), len_1) 202 | if events[7] then 203 | events[7](value) 204 | else 205 | table.insert(event_queue[7], value) 206 | if #event_queue[7] > 64 then 207 | warn(`[ZAP] {#event_queue[7]} events in queue for obtain_item. Did you forget to attach a listener?`) 208 | end 209 | end 210 | elseif id == 8 then 211 | local value 212 | if events[8] then 213 | events[8](value) 214 | else 215 | event_queue[8] += 1 216 | if event_queue[8] > 16 then 217 | warn(`[ZAP] {event_queue[8]} events in queue for trade_rejected. Did you forget to attach a listener?`) 218 | end 219 | end 220 | elseif id == 9 then 221 | local value 222 | value = {} 223 | for _ = 1, buffer.readu16(incoming_buff, read(2)) do 224 | local key_1 225 | local val_1 226 | local len_1 = buffer.readu16(incoming_buff, read(2)) 227 | key_1 = buffer.readstring(incoming_buff, read(len_1), len_1) 228 | val_1 = buffer.readu8(incoming_buff, read(1)) 229 | value[key_1] = val_1 230 | end 231 | if events[9] then 232 | events[9](value) 233 | else 234 | table.insert(event_queue[9], value) 235 | if #event_queue[9] > 64 then 236 | warn(`[ZAP] {#event_queue[9]} events in queue for update_inventory. Did you forget to attach a listener?`) 237 | end 238 | end 239 | elseif id == 10 then 240 | local value 241 | value = {} 242 | local len_1 = buffer.readu16(incoming_buff, read(2)) 243 | value.item = buffer.readstring(incoming_buff, read(len_1), len_1) 244 | value.amount = buffer.readu32(incoming_buff, read(4)) 245 | if events[10] then 246 | events[10](value) 247 | else 248 | table.insert(event_queue[10], value) 249 | if #event_queue[10] > 64 then 250 | warn(`[ZAP] {#event_queue[10]} events in queue for set_item_offer. Did you forget to attach a listener?`) 251 | end 252 | end 253 | else 254 | error("Unknown event id") 255 | end 256 | end 257 | end) 258 | local returns = { 259 | send_events = send_events, 260 | roll_item = { 261 | fire = function() 262 | alloc(1) 263 | buffer.writeu8(outgoing_buff, outgoing_apos, 1) 264 | end, 265 | }, 266 | request_trade = { 267 | fire = function(value: (Instance)) 268 | alloc(1) 269 | buffer.writeu8(outgoing_buff, outgoing_apos, 2) 270 | table.insert(outgoing_inst, value) 271 | end, 272 | }, 273 | reject_trade = { 274 | fire = function() 275 | alloc(1) 276 | buffer.writeu8(outgoing_buff, outgoing_apos, 3) 277 | end, 278 | }, 279 | set_item = { 280 | fire = function(value: ({ 281 | item: (string), 282 | amount: (number), 283 | })) 284 | alloc(1) 285 | buffer.writeu8(outgoing_buff, outgoing_apos, 4) 286 | local len_1 = #value.item 287 | alloc(2) 288 | buffer.writeu16(outgoing_buff, outgoing_apos, len_1) 289 | alloc(len_1) 290 | buffer.writestring(outgoing_buff, outgoing_apos, value.item, len_1) 291 | alloc(4) 292 | buffer.writeu32(outgoing_buff, outgoing_apos, value.amount) 293 | end, 294 | }, 295 | trade_ready = { 296 | fire = function() 297 | alloc(1) 298 | buffer.writeu8(outgoing_buff, outgoing_apos, 5) 299 | end, 300 | }, 301 | start_trade = { 302 | set_callback = function(callback: (value: ({ 303 | who: (Instance), 304 | })) -> ()): () -> () 305 | events[6] = callback 306 | for _, value in event_queue[6] do 307 | callback(value ) 308 | 309 | end 310 | event_queue[6] = {} 311 | return function() 312 | events[6] = nil 313 | end 314 | end, 315 | }, 316 | obtain_item = { 317 | set_callback = function(callback: (value: ({ 318 | obtained: (string), 319 | })) -> ()): () -> () 320 | events[7] = callback 321 | for _, value in event_queue[7] do 322 | callback(value ) 323 | 324 | end 325 | event_queue[7] = {} 326 | return function() 327 | events[7] = nil 328 | end 329 | end, 330 | }, 331 | trade_rejected = { 332 | set_callback = function(callback: () -> ()): () -> () 333 | events[8] = callback 334 | for _ = 1, event_queue[8] do 335 | callback() 336 | end 337 | event_queue[8] = 0 338 | return function() 339 | events[8] = nil 340 | end 341 | end, 342 | }, 343 | update_inventory = { 344 | set_callback = function(callback: (value: ({ [(string)]: (number) })) -> ()): () -> () 345 | events[9] = callback 346 | for _, value in event_queue[9] do 347 | callback(value ) 348 | 349 | end 350 | event_queue[9] = {} 351 | return function() 352 | events[9] = nil 353 | end 354 | end, 355 | }, 356 | set_item_offer = { 357 | set_callback = function(callback: (value: ({ 358 | item: (string), 359 | amount: (number), 360 | })) -> ()): () -> () 361 | events[10] = callback 362 | for _, value in event_queue[10] do 363 | callback(value ) 364 | 365 | end 366 | event_queue[10] = {} 367 | return function() 368 | events[10] = nil 369 | end 370 | end, 371 | }, 372 | } 373 | type Events = typeof(returns) 374 | return returns 375 | -------------------------------------------------------------------------------- /test/testkit.luau: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- 2 | -- testkit.luau 3 | -- v0.7.3 4 | -------------------------------------------------------------------------------- 5 | 6 | local color = { 7 | white_underline = function(s: string) 8 | return `\27[1;4m{s}\27[0m` 9 | end, 10 | 11 | white = function(s: string) 12 | return `\27[37;1m{s}\27[0m` 13 | end, 14 | 15 | green = function(s: string) 16 | return `\27[32;1m{s}\27[0m` 17 | end, 18 | 19 | red = function(s: string) 20 | return `\27[31;1m{s}\27[0m` 21 | end, 22 | 23 | yellow = function(s: string) 24 | return `\27[33;1m{s}\27[0m` 25 | end, 26 | 27 | red_highlight = function(s: string) 28 | return `\27[41;1;30m{s}\27[0m` 29 | end, 30 | 31 | green_highlight = function(s: string) 32 | return `\27[42;1;30m{s}\27[0m` 33 | end, 34 | 35 | gray = function(s: string) 36 | return `\27[30;1m{s}\27[0m` 37 | end, 38 | } 39 | 40 | local function convert_units(unit: string, value: number): (number, string) 41 | local prefix_colors = { 42 | [4] = color.red, 43 | [3] = color.red, 44 | [2] = color.yellow, 45 | [1] = color.yellow, 46 | [0] = color.green, 47 | [-1] = color.red, 48 | [-2] = color.yellow, 49 | [-3] = color.green, 50 | [-4] = color.red 51 | } 52 | 53 | local prefixes = { 54 | [4] = "T", 55 | [3] ="G", 56 | [2] ="M", 57 | [1] = "k", 58 | [0] = " ", 59 | [-1] = "m", 60 | [-2] = "u", 61 | [-3] = "n", 62 | [-4] = "p" 63 | } 64 | 65 | local order = 0 66 | 67 | while value >= 1000 do 68 | order += 1 69 | value /= 1000 70 | end 71 | 72 | while value ~= 0 and value < 1 do 73 | order -= 1 74 | value *= 1000 75 | end 76 | 77 | if value >= 100 then 78 | value = math.floor(value) 79 | elseif value >= 10 then 80 | value = math.floor(value * 1e1) / 1e1 81 | elseif value >= 1 then 82 | value = math.floor(value * 1e2) / 1e2 83 | end 84 | 85 | return value, prefix_colors[order](prefixes[order] .. unit) 86 | end 87 | 88 | local WALL = color.gray "│" 89 | 90 | -------------------------------------------------------------------------------- 91 | -- Testing 92 | -------------------------------------------------------------------------------- 93 | 94 | type Test = { 95 | name: string, 96 | case: Case?, 97 | cases: { Case }, 98 | duration: number, 99 | error: { 100 | message: string, 101 | trace: string 102 | }? 103 | } 104 | 105 | type Case = { 106 | name: string, 107 | result: number, 108 | line: number? 109 | } 110 | 111 | local PASS, FAIL, NONE, ERROR = 1, 2, 3, 4 112 | 113 | local skip: string? 114 | local test: Test? 115 | local tests: { Test } = {} 116 | 117 | local function output_test_result(test: Test) 118 | print(color.white(test.name)) 119 | 120 | for _, case in test.cases do 121 | local status = ({ 122 | [PASS] = color.green "PASS", 123 | [FAIL] = color.red "FAIL", 124 | [NONE] = color.yellow "NONE", 125 | [ERROR] = color.red "FAIL" 126 | })[case.result] 127 | 128 | local line = case.result == FAIL and color.red(`{case.line}:`) or "" 129 | 130 | print(`{status}{WALL} {line}{color.gray(case.name)}`) 131 | end 132 | 133 | if test.error then 134 | print(color.gray "error: " .. color.red(test.error.message)) 135 | print(color.gray "trace: " .. color.red(test.error.trace)) 136 | else 137 | print() 138 | end 139 | end 140 | 141 | local function CASE(name: string) 142 | assert(test, "no active test") 143 | 144 | local case = { 145 | name = name, 146 | result = NONE 147 | } 148 | 149 | test.case = case 150 | table.insert(test.cases, case) 151 | end 152 | 153 | local function CHECK(value: T, stack: number?): T 154 | assert(test, "no active test") 155 | local case = test.case 156 | 157 | if not case then 158 | CASE "" 159 | case = test.case 160 | end 161 | 162 | assert(case, "no active case") 163 | 164 | if case.result ~= FAIL then 165 | case.result = value and PASS or FAIL 166 | case.line = debug.info(stack and stack + 1 or 2, "l") 167 | end 168 | 169 | return value 170 | end 171 | 172 | local function TEST(name: string, fn: () -> ()) 173 | if skip and name ~= skip then return end 174 | 175 | local active = test 176 | assert(not active, "cannot start test while another test is in progress") 177 | 178 | test = { 179 | name = name, 180 | cases = {}, 181 | duration = 0 182 | }; assert(test) 183 | 184 | table.insert(tests, test) 185 | 186 | local start = os.clock() 187 | local err 188 | local success = xpcall(fn, function(m: string) 189 | err = { message = m, trace = debug.traceback(nil, 2) } 190 | end) 191 | test.duration = os.clock() - start 192 | 193 | if not test.case then CASE "" end 194 | assert(test.case, "no active case") 195 | 196 | if not success then 197 | test.case.result = ERROR 198 | test.error = err 199 | end 200 | 201 | test = nil 202 | end 203 | 204 | local function FINISH(): boolean 205 | local success = true 206 | local total_cases = 0 207 | local passed_cases = 0 208 | local duration = 0 209 | 210 | for _, test in tests do 211 | duration += test.duration 212 | for _, case in test.cases do 213 | total_cases += 1 214 | if case.result == PASS or case.result == NONE then 215 | passed_cases += 1 216 | else 217 | success = false 218 | end 219 | end 220 | 221 | output_test_result(test) 222 | end 223 | 224 | print(color.gray(string.format( 225 | `{passed_cases}/{total_cases} test cases passed in %.3f ms.`, 226 | duration*1e3 227 | ))) 228 | 229 | local fails = total_cases - passed_cases 230 | 231 | print( 232 | ( 233 | fails > 0 234 | and color.red 235 | or color.green 236 | )(`{fails} {fails == 1 and "fail" or "fails"}`) 237 | ) 238 | 239 | return success, table.clear(tests) 240 | end 241 | 242 | local function SKIP(name: string) 243 | assert(not test, "cannot skip during test") 244 | skip = name 245 | end 246 | 247 | -------------------------------------------------------------------------------- 248 | -- Benchmarking 249 | -------------------------------------------------------------------------------- 250 | 251 | type Bench = { 252 | time_start: number?, 253 | memory_start: number?, 254 | iterations: number? 255 | } 256 | 257 | local bench: Bench? 258 | 259 | function START(iter: number?): number 260 | local n = iter or 1 261 | assert(n > 0, "iterations must be greater than 0") 262 | assert(bench, "no active benchmark") 263 | assert(not bench.time_start, "clock was already started") 264 | 265 | bench.iterations = n 266 | bench.memory_start = gcinfo() 267 | bench.time_start = os.clock() 268 | return n 269 | end 270 | 271 | local function BENCH(name: string, fn: () -> ()) 272 | local active = bench 273 | assert(not active, "a benchmark is already in progress") 274 | 275 | bench = {}; assert(bench) 276 | 277 | ;(collectgarbage :: any)("collect") 278 | 279 | local mem_start = gcinfo() 280 | local time_start = os.clock() 281 | local err_msg: string? 282 | 283 | local success = xpcall(fn, function(m: string) 284 | err_msg = m .. debug.traceback(nil, 2) 285 | end) 286 | 287 | local time_stop = os.clock() 288 | local mem_stop = gcinfo() 289 | 290 | if not success then 291 | print(`{WALL}{color.red("ERROR")}{WALL} {name}`) 292 | print(color.gray(err_msg :: string)) 293 | else 294 | time_start = bench.time_start or time_start 295 | mem_start = bench.memory_start or mem_start 296 | 297 | local n = bench.iterations or 1 298 | local d, d_unit = convert_units("s", (time_stop - time_start) / n) 299 | local a, a_unit = convert_units("B", math.round((mem_stop - mem_start) / n * 1e3)) 300 | 301 | local function round(x: number): string 302 | return x > 0 and x < 10 and (x - math.floor(x)) > 0 303 | and string.format("%2.1f", x) 304 | or string.format("%3.f", x) 305 | end 306 | 307 | print(string.format( 308 | `%s %s %s %s{WALL} %s`, 309 | color.gray(round(d)), 310 | d_unit, 311 | color.gray(round(a)), 312 | a_unit, 313 | color.gray(name) 314 | )) 315 | end 316 | 317 | bench = nil 318 | end 319 | 320 | -------------------------------------------------------------------------------- 321 | -- Printing 322 | -------------------------------------------------------------------------------- 323 | 324 | local function print2(v: unknown) 325 | type Buffer = { n: number, [number]: string } 326 | type Cyclic = { n: number, [{}]: number } 327 | 328 | -- overkill concatenationless string buffer 329 | local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) 330 | local TAB = " " 331 | local indent = table.concat(table.create(stack, TAB)) 332 | 333 | if type(value) == "string" then 334 | local n = str.n 335 | str[n + 1] = "\"" 336 | str[n + 2] = value 337 | str[n + 3] = "\"" 338 | str.n = n + 3 339 | elseif type(value) ~= "table" then 340 | local n = str.n 341 | str[n + 1] = value == nil and "nil" or tostring(value) 342 | str.n = n + 1 343 | elseif next(value) == nil then 344 | local n = str.n 345 | str[n + 1] = "{}" 346 | str.n = n + 1 347 | else -- is table 348 | local tabbed_indent = indent .. TAB 349 | 350 | if cyclic[value] then 351 | str.n += 1 352 | str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`) 353 | return 354 | else 355 | cyclic.n += 1 356 | cyclic[value] = cyclic.n 357 | end 358 | 359 | str.n += 3 360 | str[str.n - 2] = "{ " 361 | str[str.n - 1] = color.gray(tostring(cyclic[value])) 362 | str[str.n - 0] = "\n" 363 | 364 | local i, v = next(value, nil) 365 | while v ~= nil do 366 | local n = str.n 367 | str[n + 1] = tabbed_indent 368 | 369 | if type(i) ~= "string" then 370 | str[n + 2] = "[" 371 | str[n + 3] = tostring(i) 372 | str[n + 4] = "]" 373 | n += 4 374 | else 375 | str[n + 2] = tostring(i) 376 | n += 2 377 | end 378 | 379 | str[n + 1] = " = " 380 | str.n = n + 1 381 | 382 | tos(v, stack + 1, str, cyclic) 383 | 384 | i, v = next(value, i) 385 | 386 | n = str.n 387 | str[n + 1] = v ~= nil and ",\n" or "\n" 388 | str.n = n + 1 389 | end 390 | 391 | local n = str.n 392 | str[n + 1] = indent 393 | str[n + 2] = "}" 394 | str.n = n + 2 395 | end 396 | end 397 | 398 | local str = { n = 0 } 399 | local cyclic = { n = 0 } 400 | tos(v, 0, str, cyclic) 401 | print(table.concat(str)) 402 | end 403 | 404 | -------------------------------------------------------------------------------- 405 | -- Equality 406 | -------------------------------------------------------------------------------- 407 | 408 | local function shallow_eq(a: {}, b: {}): boolean 409 | if #a ~= #b then return false end 410 | 411 | for i, v in next, a do 412 | if b[i] ~= v then 413 | return false 414 | end 415 | end 416 | 417 | for i, v in next, b do 418 | if a[i] ~= v then 419 | return false 420 | end 421 | end 422 | 423 | return true 424 | end 425 | 426 | local function deep_eq(a: {}, b: {}): boolean 427 | if #a ~= #b then return false end 428 | 429 | for i, v in next, a do 430 | if type(b[i]) == "table" and type(v) == "table" then 431 | if deep_eq(b[i], v) == false then return false end 432 | elseif b[i] ~= v then 433 | return false 434 | end 435 | end 436 | 437 | for i, v in next, b do 438 | if type(a[i]) == "table" and type(v) == "table" then 439 | if deep_eq(a[i], v) == false then return false end 440 | elseif a[i] ~= v then 441 | return false 442 | end 443 | end 444 | 445 | return true 446 | end 447 | 448 | -------------------------------------------------------------------------------- 449 | -- Return 450 | -------------------------------------------------------------------------------- 451 | 452 | return { 453 | test = function() 454 | return TEST, CASE, CHECK, FINISH, SKIP 455 | end, 456 | 457 | benchmark = function() 458 | return BENCH, START 459 | end, 460 | 461 | print = print2, 462 | 463 | seq = shallow_eq, 464 | deq = deep_eq, 465 | 466 | color = color 467 | } -------------------------------------------------------------------------------- /test/mock.luau: -------------------------------------------------------------------------------- 1 | type Array = { T } 2 | type Map = { [K]: V } 3 | 4 | type Request = { 5 | id: string, 6 | } 7 | 8 | type MockStoreService = { 9 | datastores: Map, 10 | 11 | Parent: { PlaceId: number, JobId: string }, 12 | 13 | GetDataStore: (MockStoreService, string) -> MockStore, 14 | } 15 | 16 | type DataInstance = { 17 | data: unknown, 18 | metadata: Map, 19 | userids: Array, 20 | version: string, 21 | time: number, 22 | deleted: boolean 23 | } 24 | type MockStore = { 25 | service: MockStoreService, 26 | name: string, 27 | data: Map, 30 | writeCount: number, 31 | createdTime: number 32 | }?>, 33 | 34 | queues: Map>, 35 | 36 | SetAsync: (self: MockStore, id: string, value: unknown, userids: Array, options: MockStoreSetOptions) -> (), 37 | GetAsync: (self: MockStore, id: string) -> (unknown, MockStoreKeyInfo?), 38 | GetVersionAsync: (self: MockStore, id: string, version: string) -> (unknown, MockStoreKeyInfo?), 39 | RemoveAsync: (self: MockStore, id: string) -> (unknown, MockStoreKeyInfo?), 40 | UpdateAsync: (self: MockStore, id: string, fn: (unknown, MockStoreKeyInfo?) -> (any?, Array?, Map?)) -> (unknown, MockStoreKeyInfo?), 41 | ListVersionsAsync: (self: MockStore, id: string, dir: Enum.SortDirection?, min: number?, max: number?, size: number?) -> MockStoreVersionPages 42 | } 43 | 44 | type MockStoreKeyInfo = { 45 | metadata: Map, 46 | userids: Array, 47 | 48 | CreatedTime: number, 49 | UpdatedTime: number, 50 | Version: string, 51 | 52 | GetMetadata: (MockStoreKeyInfo) -> Map, 53 | GetUserIds: (MockStoreKeyInfo) -> Array 54 | } 55 | 56 | type MockStoreVersionPages = { 57 | store: MockStore, 58 | id: string, 59 | ascending: boolean, 60 | min: number, max: number, 61 | size: number, 62 | current: number, 63 | initialized: boolean, 64 | list: Array, 65 | 66 | IsFinished: boolean, 67 | 68 | GetCurrentPage: (MockStoreVersionPages) -> Array, 69 | AdvanceToNextPageAsync: (MockStoreVersionPages) -> () 70 | } 71 | 72 | type MockStoreObjectVersionInfo = { 73 | CreatedTime: number, 74 | IsDeleted: boolean, 75 | Version: string 76 | } 77 | 78 | type MockStoreSetOptions = { 79 | metadata: Map, 80 | 81 | SetMetadata: (self: MockStoreSetOptions, metadata: Map) -> () 82 | } 83 | 84 | local SETTINGS = { 85 | current_time = os.time(), 86 | requests_frozen = false, 87 | simulate_outage = false, 88 | total_requests_to_deny = 0 89 | } 90 | 91 | local datamodel = { 92 | JobId = "", 93 | PlaceId = 0 94 | } 95 | 96 | local services = {} :: Array 97 | 98 | local time = table.freeze { 99 | wait = function(t: number) 100 | SETTINGS.current_time += t 101 | end, 102 | 103 | get = function(): number 104 | return SETTINGS.current_time 105 | end 106 | } 107 | 108 | local function check_if_deny() 109 | if SETTINGS.simulate_outage then 110 | return true 111 | elseif SETTINGS.total_requests_to_deny > 0 then 112 | SETTINGS.total_requests_to_deny -= 1 113 | return true 114 | else 115 | return false 116 | end 117 | end 118 | 119 | local function toid(v: string|number): string 120 | return 121 | if type(v) == "string" then v 122 | elseif type(v) == "number" then tostring(v) 123 | else error("invalid id, must be string or number", 2) 124 | end 125 | 126 | local function clone(x: T): T 127 | if type(x) == "table" then 128 | local t = table.create(#x) 129 | for i, v in next, x do 130 | t[clone(i)] = clone(v) 131 | end 132 | return t :: T & {} 133 | else 134 | return x 135 | end 136 | end 137 | 138 | local function pop(t: Array): T? 139 | return table.remove(t, 1) 140 | end 141 | 142 | local function push(t: Array, v: T) 143 | table.insert(t, v) 144 | end 145 | 146 | local function resume(thread: thread) 147 | local ok, err = coroutine.resume(thread) 148 | if not ok then error(err, 2) end 149 | end 150 | 151 | local function ycall(fn: (T...) -> U..., ...: T...): U... 152 | local thread = coroutine.create(pcall) 153 | local resume_ok, run_ok, a, b, c, d = coroutine.resume(thread, fn, ...) 154 | 155 | assert(resume_ok) 156 | 157 | if coroutine.status(thread) ~= "dead" then 158 | error("yielding is none allowed", 2) 159 | end 160 | 161 | if not run_ok then 162 | error(a, 2) 163 | end 164 | 165 | return a, b, c, d 166 | end 167 | 168 | local MockStore = {} do 169 | local MockStoreKeyInfo = {} do 170 | function GetMetadata(self: MockStoreKeyInfo) 171 | return clone(self.metadata) 172 | end 173 | 174 | function GetUserIds(self: MockStoreKeyInfo) 175 | return clone(self.userids) 176 | end 177 | 178 | function MockStoreKeyInfo.new(createdTime: number, data: DataInstance): MockStoreKeyInfo 179 | return table.freeze { 180 | metadata = clone(data.metadata), 181 | userids = clone(data.userids), 182 | 183 | CreatedTime = createdTime, 184 | UpdatedTime = data.time, 185 | Version = data.version, 186 | 187 | GetMetadata = GetMetadata, 188 | GetUserIds = GetUserIds 189 | } 190 | end 191 | end 192 | 193 | local DIRECTION_ASCENDING = 0 194 | local DIRECTION_DESCENDING = 1 195 | 196 | local MockStoreVersionPages = {} do 197 | function GetCurrentPage(self: MockStoreVersionPages): Array 198 | if not self.initialized then error("must advance to next page first", 2) end 199 | return clone(self.list) 200 | end 201 | 202 | function AdvanceToNextPageAsync(self: MockStoreVersionPages) 203 | if SETTINGS.requests_frozen then 204 | push(self.store.queues[self.id], coroutine.running()) 205 | coroutine.yield() 206 | end 207 | 208 | if check_if_deny() then error("could not advance to next page", 2) end 209 | -- todo what if new entries are added after finished is reached 210 | if self.IsFinished then error("no more pages left", 2) end 211 | 212 | local versions = assert(self.store.data[self.id]).versions 213 | 214 | if not self.initialized then -- when called for the first time 215 | self.initialized = true 216 | self.current = self.ascending and 1 or #versions 217 | end 218 | 219 | local list = {} :: Array 220 | 221 | local diff = self.ascending and self.size or -self.size 222 | 223 | for i = 224 | self.current, 225 | self.ascending and self.current + diff - 1 or self.current + diff + 1, 226 | self.ascending and 1 or -1 227 | do 228 | local instance = versions[i] 229 | 230 | if instance == nil then 231 | break 232 | end 233 | 234 | if self.ascending then 235 | if instance.time < self.min then 236 | continue 237 | elseif instance.time > self.max then 238 | break 239 | end 240 | else 241 | if instance.time > self.max then 242 | continue 243 | elseif instance.time < self.min then 244 | break 245 | end 246 | end 247 | 248 | table.insert(list, table.freeze { 249 | CreatedTime = instance.time, 250 | IsDeleted = instance.deleted, 251 | Version = instance.version 252 | }) 253 | end 254 | self.current += diff 255 | 256 | self.IsFinished = #list == 0 257 | 258 | self.list = list 259 | end 260 | 261 | function MockStoreVersionPages.new(store: MockStore, id: string, dir: number, min: number, max: number, size: number): MockStoreVersionPages 262 | id = toid(id) 263 | 264 | local self = { 265 | store = store, 266 | id = id, 267 | ascending = if dir == DIRECTION_ASCENDING then true elseif dir == DIRECTION_DESCENDING then false else error("invalid direction", 3), 268 | min = min, 269 | max = max, 270 | size = size, 271 | current = -1, 272 | initialized = false, 273 | list = {}, 274 | 275 | IsFinished = false, 276 | 277 | GetCurrentPage = GetCurrentPage, 278 | AdvanceToNextPageAsync = AdvanceToNextPageAsync 279 | } 280 | 281 | self:AdvanceToNextPageAsync() 282 | 283 | return self 284 | end 285 | end 286 | 287 | --[[ 288 | function SetAsync(self: MockStore, id: string, value: unknown, userids: Array, options: DataStoreSetOptions): string -- new version 289 | if self.SimulateOutage then error("could not set data", 2) end 290 | local versions = self.data[id] 291 | local latest = versions[#versions] 292 | local newVersion = tostring(tonumber(latest.version) :: number + 1) 293 | table.insert(versions, { 294 | 295 | }) 296 | return newVersion 297 | end 298 | ]] 299 | 300 | function Set( 301 | self: MockStore, 302 | id: string, 303 | data: unknown, 304 | userids: Array?, 305 | metadata: {}?): string 306 | id = toid(id) 307 | if data == nil then error("cannot set data to nil", 2) end 308 | 309 | if check_if_deny() then error("could not set data", 2) end 310 | 311 | local key = self.data[id] 312 | 313 | if key == nil then 314 | key = { 315 | current = nil :: any, 316 | versions = {}, 317 | writeCount = 0, 318 | createdTime = time.get() 319 | } 320 | self.data[id] = key 321 | end 322 | 323 | assert(key) 324 | 325 | key.writeCount += 1 326 | local version = tostring(key.writeCount) 327 | 328 | local instance: DataInstance = { 329 | data = clone(data), 330 | metadata = clone(metadata) or {}, 331 | userids = clone(userids) or {}, 332 | version = version, 333 | time = time.get(), 334 | deleted = false 335 | } 336 | 337 | table.insert(key.versions, instance) 338 | key.current = instance 339 | 340 | return version 341 | end 342 | 343 | function SetAsync( 344 | self: MockStore, 345 | id: string, 346 | data: unknown, 347 | userids: Array?, 348 | options: MockStoreSetOptions? 349 | ): string 350 | id = toid(id) 351 | if SETTINGS.requests_frozen then 352 | push(self.queues[id], coroutine.running()) 353 | coroutine.yield() 354 | end 355 | 356 | return Set(self, id, data, userids, options and options.metadata) 357 | end 358 | 359 | function GetAsync(self: MockStore, id: string): (unknown, MockStoreKeyInfo?) 360 | id = toid(id) 361 | if SETTINGS.requests_frozen then 362 | push(self.queues[id], coroutine.running()) 363 | coroutine.yield() 364 | end 365 | 366 | if check_if_deny() then error("could not retrieve data", 2) end 367 | 368 | local key = self.data[id] 369 | 370 | if key == nil or key.current == nil then return nil, nil end; assert(key and key.current) 371 | 372 | return clone(key.current.data), MockStoreKeyInfo.new(key.createdTime, key.current) 373 | end 374 | 375 | function GetVersionAsync(self: MockStore, id: string, version: string): (unknown, MockStoreKeyInfo?) 376 | id = toid(id) 377 | if SETTINGS.requests_frozen then 378 | push(self.queues[id], coroutine.running()) 379 | coroutine.yield() 380 | end 381 | 382 | if check_if_deny() then error("could not retrieve data", 2) end 383 | 384 | local key = self.data[id] 385 | 386 | if key == nil then return nil, nil end; assert(key) 387 | 388 | for _, instance in next, key.versions do 389 | if instance.version == version then 390 | if instance.deleted then error("version was deleted", 2) end -- todo: update when roblox fixes 391 | return clone(instance.data), MockStoreKeyInfo.new(key.createdTime, instance) 392 | end 393 | end 394 | 395 | return nil, nil 396 | end 397 | 398 | function UpdateAsync( 399 | self: MockStore, 400 | id: string, 401 | fn: (unknown, MockStoreKeyInfo?) -> (unknown, Array?, Map?), 402 | tag: string? 403 | ): (unknown, MockStoreKeyInfo?) 404 | id = toid(id) 405 | 406 | local newdata, newuserids, newmetadata; repeat 407 | local writes1 do 408 | local key = self.data[id] 409 | writes1 = key and key.writeCount or 0 410 | end 411 | 412 | local data, info = GetAsync(self, id) 413 | 414 | newdata, newuserids, newmetadata = ycall(fn, data, info) 415 | if newdata == nil then return end 416 | 417 | if SETTINGS.requests_frozen then 418 | push(self.queues[id], coroutine.running()) 419 | coroutine.yield() 420 | end 421 | 422 | local writes2 do 423 | local key = self.data[id] 424 | writes2 = key and key.writeCount or 0 425 | end 426 | until writes1 == writes2 427 | 428 | Set(self, id, newdata, newuserids, newmetadata) 429 | 430 | local key = self.data[id]; assert(key and key.current) 431 | return clone(newdata), MockStoreKeyInfo.new(key.createdTime, key.current) 432 | end 433 | 434 | local function RemoveAsync(self: MockStore, id: string): (unknown, MockStoreKeyInfo?) 435 | id = toid(id) 436 | 437 | if SETTINGS.requests_frozen then 438 | push(self.queues[id], coroutine.running()) 439 | coroutine.yield() 440 | end 441 | 442 | if check_if_deny() then error("could not remove data", 2) end 443 | 444 | local key = self.data[id] 445 | 446 | if key == nil or key.current == nil then return nil, nil end; assert(key and key.current) 447 | 448 | key.writeCount += 1 449 | 450 | key.current.deleted = true -- todo: verify behavior 451 | local data, info = key.current.data, MockStoreKeyInfo.new(key.createdTime, key.current) 452 | key.current = nil 453 | 454 | return clone(data), info 455 | end 456 | 457 | function ListVersionsAsync(self: MockStore, id: string, dir: Enum.SortDirection?, min: number?, max: number?, size: number?): MockStoreVersionPages 458 | id = toid(id) 459 | if check_if_deny() then error("could not update data", 2) end 460 | 461 | local ndir = dir or DIRECTION_ASCENDING :: any 462 | local nmin = min or 0 463 | local nmax = max or 0 464 | local nsize = size or 0 465 | 466 | if nmax == 0 then nmax = 2^50 end 467 | if nsize == 0 then nsize = 2^50 end 468 | 469 | return MockStoreVersionPages.new(self, id, ndir, nmin, nmax, nsize) 470 | end 471 | 472 | function MockStore.new(service: MockStoreService, name: string): MockStore 473 | local queues = {} :: Map> 474 | 475 | setmetatable(queues :: any, { __index = function(_, index) 476 | queues[index] = {} 477 | return queues[index] 478 | end }) 479 | 480 | return { 481 | service = service, 482 | name = name, 483 | data = {}, 484 | 485 | queues = queues, 486 | 487 | Name = name, 488 | 489 | SetAsync = SetAsync, 490 | GetAsync = GetAsync, 491 | GetVersionAsync = GetVersionAsync, 492 | RemoveAsync = RemoveAsync, 493 | UpdateAsync = UpdateAsync, 494 | ListVersionsAsync = ListVersionsAsync, 495 | } 496 | end 497 | end 498 | 499 | local MockStoreService = {} do 500 | function GetDataStore(self: MockStoreService, name: string): MockStore 501 | return self.datastores[name] 502 | end 503 | 504 | function MockStoreService.new(): MockStoreService 505 | local datastores = {} :: Map 506 | 507 | local self = { 508 | datastores = datastores, 509 | 510 | SimulateOutage = false, 511 | 512 | Parent = datamodel, 513 | 514 | GetDataStore = GetDataStore 515 | } 516 | 517 | table.insert(services, self) 518 | 519 | setmetatable(datastores :: any, { __index = function(_, index) 520 | datastores[index] = MockStore.new(self, index) 521 | return datastores[index] 522 | end }) 523 | 524 | return self 525 | end 526 | end 527 | 528 | local MockStoreSetOptions = {} do 529 | function SetMetadata(self: MockStoreSetOptions, metadata: Map) 530 | self.metadata = metadata 531 | end 532 | 533 | function MockStoreSetOptions.new() 534 | return { 535 | metadata = {}, 536 | 537 | SetMetadata = SetMetadata 538 | } 539 | end 540 | end 541 | 542 | local function requests_pop() 543 | local processed = false -- true if at least one queue was processed 544 | 545 | for _, service in services do 546 | for _, store in service.datastores do 547 | for _, queue in store.queues do 548 | local thread = pop(queue) 549 | if thread then 550 | resume(thread) 551 | processed = true 552 | end 553 | end 554 | end 555 | end 556 | 557 | return processed 558 | end 559 | 560 | local requests = table.freeze { 561 | freeze = function() 562 | SETTINGS.requests_frozen = true 563 | end, 564 | 565 | unfreeze = function() 566 | repeat until not requests_pop() 567 | SETTINGS.requests_frozen = false 568 | end, 569 | 570 | pop = requests_pop 571 | } 572 | 573 | return { 574 | DataStoreService = MockStoreService.new(), 575 | DataStoreSetOptions = MockStoreSetOptions, 576 | 577 | requests = requests, 578 | time = time, 579 | 580 | set_jobid = function(id: string) 581 | datamodel.JobId = id 582 | end, 583 | 584 | deny_requests = function(n: number) 585 | SETTINGS.total_requests_to_deny = n 586 | end, 587 | 588 | simulate_outage = function(f: boolean) 589 | SETTINGS.simulate_outage = f 590 | end 591 | } 592 | -------------------------------------------------------------------------------- /src/session.luau: -------------------------------------------------------------------------------- 1 | local types = require("./types") 2 | local budget = require("./budget") 3 | local retry = require("./retry") 4 | local LOG = require("./log") 5 | local actions = require("./actions") 6 | local flags = require("./flags") 7 | local task = require("./task") 8 | local transaction = require("./checktransaction") 9 | local util = require("./util") 10 | local THROW = require("./throw") 11 | 12 | local AUTOSAVE_DELAY = 15 13 | 14 | local profilebegin = debug.profilebegin or function() end :: never 15 | local profileend = debug.profileend or function() end :: never 16 | 17 | local NOOP = function() end 18 | 19 | type JSON = types.JSON 20 | type Action = types.Action 21 | type Store = types.Store 22 | type Session = types.Session 23 | type StoredData = types.StoredData 24 | type TransactionId = types.TransactionId 25 | type TransactionInfo = types.TransactionInfo 26 | type TransactionAction = types.TransactionAction 27 | type TransactionData = types.TransactionData 28 | 29 | local function merge(a: { T }, b: { T }): { T } 30 | local dst = table.create(#a + #b) 31 | table.move(a, 1, #a, 1, dst) 32 | table.move(b, 1, #b, #a + 1, dst) 33 | return dst 34 | end 35 | 36 | local function migrate(session: Session, stored: StoredData) 37 | local migrations = session._.options.migrations 38 | 39 | if #migrations == #stored.migrations_performed then return false end 40 | LOG(`{session.name}: Performing Migration`) 41 | for i, migration in migrations do 42 | if table.find(stored.migrations_performed, migration.step) then continue end 43 | LOG(`{session.name}: Migration Step {migration.step}`) 44 | 45 | local result = migration.migrate(stored.data) 46 | if result == nil then THROW("migration_does_not_return", migration.step) end 47 | stored.data = result 48 | 49 | table.insert(stored.migrations_performed, migration.step) 50 | end 51 | return true 52 | end 53 | 54 | local function perform_transaction(store: Store, stored: StoredData, action: TransactionAction) 55 | local fn = actions.obtain_transaction_action(store, action.fn) 56 | local values = action.values 57 | 58 | local result = fn(util.clone(stored.data), unpack(values)) 59 | if result == nil then THROW("cannot_cancel_action", action.fn) end 60 | stored.data = result 61 | end 62 | 63 | local function create_migrations_table(session: Session) 64 | local migrations = {} 65 | for i, migration in session._.options.migrations do 66 | table.insert(migrations, migration.step) 67 | end 68 | return migrations 69 | end 70 | 71 | local function check_transaction(store: Store, session: Session, data: StoredData) 72 | for _, info in data.pending_transactions do 73 | for _, value in info.success do 74 | if store._transaction_names[value.fn] then continue end 75 | session._.why_bad = `Transaction could not be processed: Unable to recognize the action "{value.fn}"` 76 | LOG(session._.why_bad) 77 | return false 78 | end 79 | end 80 | return true 81 | end 82 | 83 | local function check_migrations(session: Session, data: StoredData) 84 | local t = create_migrations_table(session) 85 | for _, migration in data.migrations_performed do 86 | if table.find(t, migration) then continue end 87 | session._.why_bad = `Migration could not be completed: Unable to apply "{migration}"` 88 | LOG(session._.why_bad) 89 | return false 90 | end 91 | return true 92 | end 93 | 94 | local function run_updated(session: Session) 95 | local data = session._.cached.data 96 | local fn = session._.updated 97 | local copy = if flags.strict then data else util.deep_freeze(util.clone(data)) 98 | 99 | return fn(copy) 100 | end 101 | 102 | local function update( 103 | session: Session, 104 | changes: { { any } }, 105 | pending_transactions: { [TransactionId]: TransactionData }, 106 | stored: StoredData, 107 | db_key_info: DataStoreKeyInfo? 108 | ): StoredData? 109 | 110 | local store = session._.store 111 | local did_anything = false 112 | local is_nil = stored == nil 113 | if stored == nil then 114 | stored = { 115 | pending_transactions = {}, 116 | migrations_performed = create_migrations_table(session), 117 | data = session._.options.default_data() 118 | } 119 | end 120 | 121 | LOG(`Applying {#changes} changes to {session.name}`) 122 | 123 | if not check_migrations(session, stored) then 124 | session._.finished = "done" 125 | return nil 126 | end 127 | if not check_transaction(store, session, stored) then session._.finished = "done"; return nil end 128 | 129 | for id, data in stored.pending_transactions do 130 | local status: "completed" | "failed" | "unknown" = transaction.is_transaction_complete(id) 131 | LOG(`{session.name}: Pending Transaction Status {id}: {status}`) 132 | if status == "completed" then 133 | for _, action in data.success do 134 | perform_transaction(store, stored, action) 135 | end 136 | stored.pending_transactions[id] = nil 137 | did_anything = true 138 | elseif status == "failed" then 139 | for _, action in data.failure do 140 | perform_transaction(store, stored, action) 141 | end 142 | stored.pending_transactions[id] = nil 143 | did_anything = true 144 | elseif status == "unknown" then 145 | -- transaction status is unknown. nothing happens 146 | else 147 | error("unhandled case") 148 | end 149 | end 150 | 151 | if migrate(session, stored) then did_anything = true end 152 | 153 | for id, data in pending_transactions do 154 | LOG(`Added transaction {id} to {session.name}`) 155 | stored.pending_transactions[id] = data 156 | did_anything = true 157 | end 158 | 159 | for _, action in changes do 160 | local fn = action[1] 161 | local s, l = debug.info(action[1], "sl") 162 | LOG(`Applying action {s}:{l} with`, unpack(action, 2)) 163 | local result = fn(stored.data, unpack(action, 2)) 164 | if result == nil then continue end 165 | stored.data = result 166 | did_anything = true 167 | end 168 | 169 | if did_anything == false and not is_nil then 170 | LOG(`No data has updated for {session.name}. Cancelling update.`) 171 | return nil 172 | end 173 | 174 | LOG(`Returning updated data for {session.name}`) 175 | 176 | return stored, session._.user_ids 177 | end 178 | 179 | local function pull_auto(session: Session, transactions: { [TransactionId]: TransactionData }) 180 | 181 | budget.delay_until_meets_budget("update", 5) 182 | 183 | local stored_changes = {} 184 | local store, key = session._.datastore, session.key 185 | 186 | session._.next_save_opportunity = math.huge 187 | 188 | LOG("saving", session.name) 189 | local ok, result: StoredData = pcall(function() 190 | LOG("attempting auto-save") 191 | local new_data 192 | store:UpdateAsync(key, function(value, keyinfo) 193 | stored_changes = merge(stored_changes, session._.changes) 194 | session._.changes = {} 195 | 196 | LOG("retrieved", value) 197 | local updated_value = update(session, stored_changes, transactions, value, keyinfo) 198 | new_data = if updated_value == nil then value else updated_value 199 | LOG(if updated_value ~= nil then "saved new data" else "cancelled update as no change has been made") 200 | 201 | return updated_value 202 | end) 203 | 204 | LOG("FINISHED SAVE") 205 | return new_data 206 | end) 207 | 208 | session._.next_save_opportunity = session._.pull_delay 209 | 210 | if not ok then 211 | session._.changes = merge(stored_changes, session._.changes) 212 | error(`Failed to pull. This issue is not because of {result}`) 213 | end 214 | 215 | session._.first_request_succeeded = true 216 | 217 | for _, fn in session._.waiting_for_saved do 218 | fn() 219 | end 220 | table.clear(session._.waiting_for_saved) 221 | 222 | if result == nil then 223 | session._.why_bad = "missing data" 224 | end 225 | 226 | if session._.why_bad then 227 | error(`Failed to pull as the server has encountered some error: {session._.why_bad}`) 228 | end 229 | 230 | for _, action in session._.changes do 231 | local fn = action[1] 232 | local s, l = debug.info(action[1], "sl") 233 | LOG(`Applying action {s}:{l} with`, unpack(action, 2)) 234 | local new_data = fn(result.data, unpack(action, 2)) 235 | if new_data == nil then continue end 236 | result.data = new_data 237 | end 238 | 239 | LOG("value is", result) 240 | session._.cached = result 241 | LOG(`{session.name} calling updated -`, debug.info(session._.updated, "sl")) 242 | run_updated(session) 243 | end 244 | 245 | local function force_pull(session: Session) 246 | LOG("forcing save") 247 | local old_changes = {} 248 | local store, key = session._.datastore, session.key 249 | 250 | session._.next_save_opportunity = math.huge 251 | 252 | local result: StoredData = retry.regular_retry(function() 253 | LOG("waiting until budget is met") 254 | budget.delay_until_meets_budget("update", 5) 255 | local new_data 256 | LOG("performing save") 257 | store:UpdateAsync(key, function(value, keyinfo) 258 | old_changes = merge(old_changes, session._.changes) 259 | session._.changes = {} 260 | 261 | LOG("retrieved", value) 262 | local updated_value = update(session, old_changes, {}, value, keyinfo) 263 | new_data = if updated_value == nil then value else updated_value 264 | LOG(if updated_value ~= nil then "saved new data" else "cancelled update as no change has been made") 265 | 266 | return updated_value 267 | end) 268 | LOG("finished") 269 | return new_data 270 | end, 1) 271 | session._.first_request_succeeded = true 272 | 273 | for _, fn in session._.waiting_for_saved do 274 | fn() 275 | end 276 | table.clear(session._.waiting_for_saved) 277 | 278 | session._.next_save_opportunity = session._.pull_delay 279 | session._.cached = if result == nil then session._.cached else result 280 | 281 | for _, action in session._.changes do 282 | local fn = action[1] 283 | local s, l = debug.info(action[1], "sl") 284 | LOG(`Applying action {s}:{l} with`, unpack(action, 2)) 285 | local new_data = fn(result.data, unpack(action, 2)) 286 | if new_data == nil then continue end 287 | result.data = new_data 288 | end 289 | 290 | LOG(`{session.name} calling updated on force`, debug.info(session._.updated, "sl")) 291 | run_updated(session) 292 | end 293 | 294 | local function thread(session: Session) 295 | while true do 296 | -- LOG("performing auto-save in", session._.next_save_opportunity, "for", session.name) 297 | if session._.next_save_opportunity > 0 then 298 | local dt = task.wait() 299 | session._.next_save_opportunity -= dt 300 | continue 301 | end 302 | LOG("triggering auto-save for", session.name) 303 | local ok, err = pcall(pull_auto, session, {}) -- silence autosave error if emitted 304 | if not ok then warn("autosave error:", err) end 305 | if session._.why_bad then return end 306 | end 307 | end 308 | 309 | local function patch_transaction(session: Session, fn: Action, ...: U...) 310 | local store = session._.store 311 | 312 | if flags.strict then 313 | for i = 1, select("#", ...) do 314 | local ok, err: util.JSONError = pcall(util.check_json_valid, select(i, ...)) 315 | 316 | if ok == false then 317 | THROW("strict_pass_not_json_into_action", tostring(err)) 318 | end 319 | end 320 | 321 | profilebegin("strict mode") 322 | local start = session._.cached.data 323 | local a = util.clone(start) 324 | a = fn(a, ...) 325 | 326 | local b = util.clone(start) 327 | b = fn(b, ...) 328 | 329 | if a == nil and b == nil then return false end 330 | 331 | -- check if it's valid json in strict mode 332 | local ok, err: util.JSONError = pcall(util.check_json_valid, a) 333 | 334 | if ok == false then 335 | THROW("strict_not_json_from_action", debug.info(fn, "s"), tostring(err)) 336 | end 337 | -- it's valid json, but we have to make sure it's pure 338 | local ok, where = util.delta_eq(a, b) 339 | if not ok then 340 | THROW("strict_impure_action", debug.info(fn, "sl"), where) 341 | end 342 | profileend() 343 | 344 | if session._.options.schema and not session._.options.schema(a) then 345 | THROW("schema_validation_error", session.name, debug.info(fn, "n")) 346 | end 347 | else 348 | local result = fn(util.clone(session._.cached.data), ...) 349 | 350 | if result == nil then return false end 351 | 352 | if session._.options.schema and not session._.options.schema(result) then 353 | THROW("schema_validation_error", session.name, debug.info(fn, "n")) 354 | end 355 | end 356 | 357 | local transaction = flags.processing_transaction :: TransactionInfo 358 | local records = transaction.sessions[session] or { success = {}, failure = {} } 359 | local record = actions.create_transaction_record(store, fn, ...) 360 | 361 | if transaction.state == "failure" then 362 | LOG(`Adding transaction action on failure "{record.fn}" for {session.name}`) 363 | table.insert(records.failure, record) 364 | elseif transaction.state == "success" then 365 | LOG(`Adding transaction action on success "{record.fn}" for {session.name}`) 366 | table.insert(records.success, record) 367 | end 368 | 369 | transaction.sessions[session] = records 370 | return true 371 | end 372 | 373 | local function patch_regular(session: Session, fn: Action, ...: U...): boolean 374 | local s, l = debug.info(fn, "sl") 375 | LOG(`Performing {s}:{l} for {session.name}`) 376 | 377 | if flags.strict then 378 | profilebegin("strict mode") 379 | local start = session._.cached.data 380 | local a = util.clone(start) 381 | a = fn(a, ...) 382 | 383 | local b = util.clone(start) 384 | b = fn(b, ...) 385 | 386 | if a == nil and b == nil then return false end 387 | 388 | -- check if it's valid json in strict mode 389 | local ok, err: util.JSONError = pcall(util.check_json_valid, a) 390 | 391 | if ok == false then 392 | THROW("strict_not_json_from_action", debug.info(fn, "s"), tostring(err)) 393 | end 394 | -- it's valid json, but we have to make sure it's pure 395 | local ok, where = util.delta_eq(a, b) 396 | if not ok then 397 | THROW("strict_impure_action", debug.info(fn, "sl"), where) 398 | end 399 | profileend() 400 | 401 | if session._.options.schema and not session._.options.schema(a) then 402 | THROW("schema_validation_error", session.name, debug.info(fn, "n")) 403 | end 404 | 405 | table.insert(session._.changes, actions.create_record(fn, ...) ) 406 | session._.cached.data = a 407 | run_updated(session) 408 | else 409 | local result = fn(session._.cached.data, ...) 410 | 411 | if result == nil then return false end 412 | 413 | if session._.options.schema and not session._.options.schema(result) then 414 | THROW("schema_validation_error", session.name, debug.info(fn, "n")) 415 | end 416 | 417 | table.insert(session._.changes, actions.create_record(fn, ...) ) 418 | session._.cached.data = result 419 | run_updated(session) 420 | end 421 | return true 422 | end 423 | 424 | local function commit_transaction_early(session: Session, fn: Action, ...: U...) 425 | local s, l = debug.info(fn, "sl") 426 | LOG(`Performing {s}:{l} for {session.name}`) 427 | 428 | if flags.strict then 429 | profilebegin("strict mode") 430 | local start = session._.cached.data 431 | local a = util.clone(start) 432 | a = fn(a, ...) 433 | 434 | local b = util.clone(start) 435 | b = fn(b, ...) 436 | 437 | if a == nil or b == nil then THROW("cannot_cancel_action", debug.info(fn, "n")) end 438 | 439 | -- check if it's valid json in strict mode 440 | local ok, err: util.JSONError = pcall(util.check_json_valid, a) 441 | 442 | if ok == false then 443 | THROW("strict_not_json_from_action", debug.info(fn, "s"), tostring(err)) 444 | end 445 | -- it's valid json, but we have to make sure it's pure 446 | local ok, where = util.delta_eq(a, b) 447 | if not ok then 448 | THROW("strict_impure_action", debug.info(fn, "sl"), where) 449 | end 450 | profileend() 451 | 452 | if session._.options.schema and not session._.options.schema(a) then 453 | THROW("schema_validation_error", session.name, debug.info(fn, "n")) 454 | end 455 | 456 | session._.cached.data = a 457 | run_updated(session) 458 | else 459 | local result = fn(session._.cached.data, ...) 460 | 461 | if session._.options.schema and not session._.options.schema(result) then 462 | THROW("schema_validation_error", session.name, debug.info(fn, "n")) 463 | end 464 | 465 | session._.cached.data = result 466 | end 467 | end 468 | 469 | local function patch(session: Session, fn: Action, ...: U...) 470 | if session._.finished then THROW("session_already_stopped") end 471 | 472 | actions.ASSERT_ACTION(session._.store, fn) 473 | if flags.processing_transaction then 474 | return patch_transaction(session, fn, ...) 475 | else 476 | return patch_regular(session, fn, ...) 477 | end 478 | end 479 | 480 | local function updated(session: Session, new: (data: T) -> ()) 481 | if session._.finished then THROW("session_already_stopped") end 482 | 483 | session._.updated = new 484 | LOG(`{session.name} calling updated register`, debug.info(new, "sl")) 485 | run_updated(session) 486 | end 487 | 488 | local function stop(session: Session) 489 | if session._.finished ~= false then return end 490 | session._.finished = "saving" 491 | session._.updated = NOOP 492 | task.cancel(session._.thread) 493 | task.spawn(function() 494 | force_pull(session) 495 | session._.finished = "done" 496 | end) 497 | end 498 | 499 | local function await(session: Session) 500 | if session._.finished == "done" then return end 501 | 502 | local ok = false 503 | table.insert(session._.waiting_for_saved, function() 504 | ok = true 505 | end) 506 | while not ok do task.wait() end 507 | end 508 | 509 | local function bad(session: Session) 510 | return session._.why_bad 511 | end 512 | 513 | local function add_userid(session: Session, user_id: number) 514 | if session._.finished then THROW("session_already_stopped") end 515 | table.insert(session._.user_ids, user_id) 516 | end 517 | 518 | local function loaded(session: Session) 519 | return session._.first_request_succeeded 520 | end 521 | 522 | local function load_session(self: Store, key: string): Session 523 | local session = { 524 | _ = { 525 | store = self, 526 | datastore = self._store, 527 | options = self._options, 528 | changes = {}, 529 | pending_transactions = {}, 530 | waiting_for_saved = {}, 531 | next_save_opportunity = 0, 532 | updated = NOOP, 533 | user_ids = {}, 534 | pull_delay = self._options.pull_delay or AUTOSAVE_DELAY, 535 | cached = { 536 | pending_transactions = {}, 537 | migrations_performed = {}, 538 | data = self._options.default_data() 539 | }, 540 | first_request_succeeded = false, 541 | finished = false, 542 | }, 543 | 544 | name = `{self._store.Name}:{key}`, 545 | key = key 546 | } 547 | 548 | session.patch = patch 549 | session.updated = updated 550 | session.stop = stop 551 | session.userid = add_userid 552 | session.loaded = loaded 553 | session.bad = bad 554 | session.await = await 555 | 556 | session._.thread = task.spawn(thread, session :: any) 557 | 558 | return session :: any 559 | end 560 | 561 | return { 562 | load_session = load_session, 563 | pull_auto = pull_auto, 564 | force_pull = force_pull, 565 | perform_transaction = perform_transaction, 566 | commit_transaction_early = commit_transaction_early 567 | } 568 | -------------------------------------------------------------------------------- /test/tests.luau: -------------------------------------------------------------------------------- 1 | local testkit = require("./testkit") 2 | local ds = require("./mock") 3 | local skilift = require("../src/lib") 4 | local task = require("../src/task") 5 | 6 | local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() 7 | 8 | type PlayerData = { 9 | money: number 10 | } 11 | 12 | skilift.strict(false) 13 | skilift.mock(ds.DataStoreService) 14 | 15 | local function schema(t) 16 | if typeof(t) ~= "table" then return false end 17 | if typeof(t.money) ~= "number" then return false end 18 | return true 19 | end 20 | 21 | TEST("store", function() 22 | 23 | do CASE "create store" 24 | local store = skilift.store { 25 | name = "test", 26 | migrations = {}, 27 | default_data = function() 28 | return { 29 | money = 0 30 | } 31 | end 32 | } 33 | 34 | CHECK(store) 35 | CHECK(store._store.Name) 36 | end 37 | 38 | local store = skilift.store { 39 | name = "test", 40 | migrations = {}, 41 | default_data = function() 42 | return { 43 | money = 0 44 | } 45 | end 46 | } 47 | 48 | do CASE "register action" 49 | local function increment_money(data: PlayerData, n: number) 50 | data.money += n 51 | return data 52 | end 53 | 54 | store:action(increment_money) 55 | 56 | CHECK(store._allowed_actions[increment_money]) 57 | end 58 | 59 | do CASE "register transaction action" 60 | local function increment_money(data: PlayerData, n: number) 61 | data.money += n 62 | return data 63 | end 64 | 65 | store:transaction("increment_money_1", increment_money) 66 | 67 | CHECK(store._transaction_actions[increment_money] == "increment_money_1") 68 | CHECK(store._transaction_names["increment_money_1"] == increment_money) 69 | end 70 | 71 | end) 72 | 73 | TEST("session", function() 74 | local store = skilift.store { 75 | name = "test", 76 | migrations = {}, 77 | default_data = function() 78 | return { 79 | money = 0, 80 | nested = { money = 0 } 81 | } 82 | end 83 | } 84 | 85 | do CASE "load" 86 | local session = store:load("load") 87 | CHECK(session) 88 | session:stop() 89 | end 90 | 91 | do CASE "updated" 92 | -- updated should always fire once 93 | local session = store:load("updated") 94 | local n = 0 95 | 96 | local function fn(value) 97 | n += 1 98 | CHECK(value.money) 99 | end 100 | 101 | session:updated(fn) 102 | 103 | CHECK(session._.updated == fn) 104 | task.step(1) 105 | CHECK(n > 0) 106 | session:stop() 107 | end 108 | 109 | do CASE "auto-pull" 110 | local session = store:load("auto-save") 111 | local n = 0 112 | 113 | local function fn(value) 114 | n += 1 115 | CHECK(value.money == 0) 116 | end 117 | 118 | session:updated(fn) 119 | 120 | CHECK(session._.updated == fn) 121 | 122 | CHECK(n == 1) 123 | task.step(100) 124 | CHECK(n == 2) 125 | -- trigger a change 126 | session._.datastore:SetAsync("auto-save", { 127 | pending_transactions = {}, 128 | migrations_performed = {}, 129 | data = { money = 0 } 130 | }) 131 | task.step(100) 132 | CHECK(n == 3) 133 | 134 | session:stop() 135 | end 136 | 137 | do CASE "patch & stop" 138 | local session = store:load("patch") 139 | local expected = 0 140 | local n = 0 141 | 142 | local function increment_money(data: PlayerData, n: number) 143 | data.money += n 144 | return data 145 | end 146 | 147 | store:action(increment_money) 148 | 149 | local function fn(value) 150 | n += 1 151 | print("expected", expected, value.money) 152 | CHECK(value.money == expected) 153 | end 154 | 155 | session:updated(fn) 156 | 157 | CHECK(n == 1) 158 | task.step(100) 159 | CHECK(n == 2) 160 | expected += 10 161 | session:patch(increment_money, 10) 162 | CHECK(n == 3) 163 | task.step(100) 164 | CHECK(n == 4) 165 | expected += 10 166 | session:patch(increment_money, 10) 167 | CHECK(n == 5) 168 | 169 | session:stop() 170 | CHECK(session._.datastore:GetAsync("patch").data.money == expected) 171 | end 172 | 173 | do CASE "skip update if nil" 174 | local session = store:load("skip-update") 175 | local expected = 0 176 | local n = 0 177 | 178 | local function increment_money(data: PlayerData, n: number) 179 | data.money += n 180 | return data 181 | end 182 | 183 | local function skip(data: PlayerData) 184 | return nil 185 | end 186 | 187 | store:action(skip) 188 | store:action(increment_money) 189 | 190 | local function fn(value) 191 | n += 1 192 | print("expected", expected, value.money) 193 | CHECK(value.money == expected) 194 | end 195 | 196 | session:updated(fn) 197 | 198 | CHECK(n == 1) 199 | task.step(100) 200 | CHECK(n == 2) 201 | session:patch(skip) 202 | CHECK(n == 2) 203 | task.step(100) 204 | CHECK(n == 3) 205 | expected += 10 206 | session:patch(increment_money, 10) 207 | CHECK(n == 4) 208 | 209 | session:stop() 210 | CHECK(session._.datastore:GetAsync("skip-update").data.money == expected) 211 | end 212 | 213 | do CASE "patch nested" 214 | local session = store:load("patch") 215 | 216 | local function patch(data, n: number) 217 | data.nested.money += n 218 | return data 219 | end 220 | 221 | store:action(patch) 222 | 223 | session:patch(patch, 10) 224 | 225 | session:stop() 226 | end 227 | 228 | end) 229 | 230 | TEST("transaction", function() 231 | 232 | local store = skilift.store { 233 | name = "test", 234 | migrations = {}, 235 | default_data = function() 236 | return { 237 | money = 0, 238 | ok = 0, 239 | fail = 0 240 | } 241 | end 242 | } 243 | 244 | do CASE "call success action" 245 | local function success_action(data) 246 | data.ok += 1 247 | return data 248 | end 249 | 250 | store:transaction("success-0", success_action) 251 | 252 | local session2 = store:load("key-2") 253 | local session1 = store:load("key-1") 254 | 255 | local status = skilift.transaction(function() 256 | session1:patch(success_action) 257 | session2:patch(success_action) 258 | end) 259 | 260 | CHECK(status) 261 | CHECK(session1._.cached.data.ok == 1) 262 | CHECK(session2._.cached.data.ok == 1) 263 | end 264 | 265 | do CASE "call failure action" 266 | local function success_action(data) 267 | data.ok += 1 268 | return data 269 | end 270 | 271 | local function fail_action(data) 272 | data.fail += 1 273 | return data 274 | end 275 | 276 | store:transaction("success-1", success_action) 277 | store:transaction("failure-1", fail_action) 278 | 279 | local session2 = store:load("key-2") 280 | local session1 = store:load("key-1") 281 | 282 | -- we need to simulate a failure within the ds library. 283 | ds.deny_requests(2) 284 | print(session1:bad()) 285 | local status = skilift.transaction(function() 286 | session1:patch(success_action) 287 | session2:patch(success_action) 288 | end, function() 289 | session1:patch(fail_action) 290 | session2:patch(fail_action) 291 | end) 292 | 293 | CHECK(not status) 294 | CHECK(session1._.cached.data.fail == 1) 295 | CHECK(session2._.cached.data.fail == 1) 296 | end 297 | 298 | end) 299 | 300 | TEST("view", function() 301 | local store = skilift.store { 302 | name = "test", 303 | migrations = {}, 304 | default_data = function() 305 | return { 306 | money = 0 307 | } 308 | end 309 | } 310 | 311 | do CASE "create view" 312 | local view = store:view("key") 313 | CHECK(view) 314 | end 315 | 316 | do CASE "latest" 317 | store._store:SetAsync("read-latest", { data = { money = 100 } }) 318 | local view = store:view("read-latest") 319 | local value = view:latest() 320 | CHECK(value.money == 100) 321 | end 322 | 323 | do CASE "latest no data" 324 | local view = store:view("read-latest-no-data") 325 | local value = view:latest() 326 | CHECK(value == nil) 327 | end 328 | 329 | do CASE "get version" 330 | local view = store:view("read-version") 331 | local version = store._store:SetAsync("read-version", { data = { money = 100 } }) 332 | local version2 = store._store:SetAsync("read-version", { data = { money = 200 } }) 333 | 334 | local data = view:version(version) 335 | CHECK(data.money == 100) 336 | local data = view:version(version2) 337 | CHECK(data.money == 200) 338 | end 339 | 340 | end) 341 | 342 | TEST("strict mode", function() 343 | skilift.strict(true) 344 | 345 | local store = skilift.store { 346 | name = "test", 347 | migrations = {}, 348 | default_data = function() 349 | return { 350 | money = 0, 351 | test = nil :: any 352 | } 353 | end 354 | } 355 | 356 | local function impure_action(data) 357 | data.money = math.random() 358 | return data 359 | end 360 | 361 | local function non_real_number(data) 362 | data.money = math.huge 363 | return data 364 | end 365 | 366 | local function non_string_key(data) 367 | data[true] = true 368 | return data 369 | end 370 | 371 | local function recursive_table(data) 372 | data.money = data :: any 373 | return data 374 | end 375 | 376 | local function bad_type(data) 377 | data.test = vector.create(0, 0, 0) 378 | return data 379 | end 380 | 381 | local function invalid_utf8(data) 382 | data.test = "\237\190\140" 383 | return data 384 | end 385 | 386 | local function valid_utf8(data) 387 | data.test = "meow :3" 388 | return data 389 | end 390 | 391 | store:action(impure_action) 392 | store:action(non_real_number) 393 | store:action(non_string_key) 394 | store:action(recursive_table) 395 | store:action(bad_type) 396 | store:action(invalid_utf8) 397 | store:action(valid_utf8) 398 | store:transaction("recursive-table", recursive_table) 399 | 400 | local function update(data: PlayerData) 401 | data.money += 1 402 | return data 403 | end 404 | store:action(update) 405 | store:transaction('increment', update) 406 | 407 | do CASE "multiple updates only update once" 408 | local session = store:load("key") 409 | local expected = 0 410 | local first = false 411 | 412 | session:updated(function(data) 413 | if first then first = false; return end 414 | print(data.money, expected) 415 | CHECK(data.money == expected) 416 | end) 417 | 418 | expected += 1 419 | session:patch(update) 420 | 421 | expected += 1 422 | session:patch(update) 423 | 424 | expected += 1 425 | first = true 426 | skilift.transaction(function() 427 | session:patch(update) 428 | end) 429 | end 430 | 431 | do CASE "fail impure function" 432 | local session = store:load("impure") 433 | 434 | local ok = pcall(function() 435 | session:patch(impure_action) 436 | end) 437 | 438 | CHECK(ok == false) 439 | end 440 | 441 | do CASE "ok valid utf8" 442 | local session = store:load("impure") 443 | 444 | local ok, err = pcall(function() 445 | session:patch(valid_utf8) 446 | end) 447 | 448 | print(err) 449 | CHECK(ok == true) 450 | end 451 | 452 | do CASE "fail invalid json utf8" 453 | local session = store:load("impure") 454 | 455 | local ok = pcall(function() 456 | session:patch(invalid_utf8) 457 | end) 458 | 459 | CHECK(ok == false) 460 | end 461 | 462 | do CASE "fail invalid json non-real number" 463 | local session = store:load("impure") 464 | 465 | local ok, err: string = pcall(function() 466 | session:patch(non_real_number) 467 | end) 468 | 469 | CHECK(ok == false) 470 | CHECK(err:find("not a real number")) 471 | end 472 | 473 | do CASE "fail invalid json string key" 474 | local session = store:load("impure") 475 | 476 | local ok, err: string = pcall(function() 477 | session:patch(non_string_key) 478 | end) 479 | 480 | CHECK(ok == false) 481 | CHECK(err:find("keys are string")) 482 | end 483 | 484 | do CASE "fail invalid json recursive" 485 | local session = store:load("impure") 486 | 487 | local ok, err: string = pcall(function() 488 | session:patch(recursive_table) 489 | end) 490 | 491 | CHECK(ok == false) 492 | CHECK(err:find("recursive")) 493 | end 494 | 495 | do CASE "fail invalid json bad type" 496 | local session = store:load("impure") 497 | 498 | local ok, err: string = pcall(function() 499 | session:patch(bad_type) 500 | end) 501 | 502 | CHECK(ok == false) 503 | CHECK(err:find("encode")) 504 | end 505 | 506 | do CASE "fail invalid json transaction" 507 | local session = store:load("transaction") 508 | 509 | local ok, err = pcall(function() 510 | skilift.transaction(function() 511 | session:patch(recursive_table, { vector.create(0, 0, 0) }) 512 | end) 513 | end) 514 | 515 | CHECK(ok == false) 516 | CHECK(err:find("only accept valid JSON")) 517 | end 518 | 519 | skilift.strict(false) 520 | end) 521 | 522 | TEST("schema", function() 523 | 524 | local store = skilift.store { 525 | name = "test-schema", 526 | migrations = {}, 527 | schema = schema, 528 | default_data = function() 529 | return { 530 | money = 0, 531 | nested = { money = 0 } 532 | } 533 | end 534 | } 535 | 536 | do CASE "load" 537 | local session = store:load("load") 538 | CHECK(session) 539 | session:stop() 540 | end 541 | 542 | do CASE "updated" 543 | -- updated should always fire once 544 | local session = store:load("updated") 545 | local n = 0 546 | 547 | local function fn(value) 548 | n += 1 549 | CHECK(value.money) 550 | end 551 | 552 | session:updated(fn) 553 | 554 | CHECK(session._.updated == fn) 555 | task.step(1) 556 | CHECK(n > 0) 557 | session:stop() 558 | end 559 | 560 | do CASE "auto-pull" 561 | local session = store:load("auto-save") 562 | local n = 0 563 | 564 | local function fn(value) 565 | n += 1 566 | CHECK(value.money == 0) 567 | end 568 | 569 | session:updated(fn) 570 | 571 | CHECK(session._.updated == fn) 572 | 573 | CHECK(n == 1) 574 | task.step(1) 575 | CHECK(n == 1) 576 | task.step(100) 577 | CHECK(n == 2) 578 | 579 | session:stop() 580 | end 581 | 582 | do CASE "patch & stop" 583 | local session = store:load("patch") 584 | local expected = 0 585 | local n = 0 586 | 587 | local function increment_money(data: PlayerData, n: number) 588 | data.money += n 589 | return data 590 | end 591 | 592 | store:action(increment_money) 593 | 594 | local function fn(value) 595 | n += 1 596 | CHECK(value.money == expected) 597 | end 598 | 599 | session:updated(fn) 600 | 601 | CHECK(n == 1) 602 | task.step(100) 603 | CHECK(n == 2) 604 | expected += 10 605 | session:patch(increment_money, 10) 606 | CHECK(n == 3) 607 | task.step(100) 608 | CHECK(n == 4) 609 | expected += 10 610 | session:patch(increment_money, 10) 611 | CHECK(n == 5) 612 | 613 | CHECK(store._store:GetAsync("patch").data.money ~= expected) 614 | session:stop() 615 | CHECK(store._store:GetAsync("patch").data.money == expected) 616 | end 617 | 618 | do CASE "fail bad patch" 619 | local session = store:load("patch-schema-fail") 620 | 621 | local function bad_money(data: PlayerData) 622 | data.money = nil :: any 623 | return data 624 | end 625 | 626 | store:action(bad_money) 627 | 628 | local ok = pcall(function() 629 | session:patch(bad_money) 630 | end) 631 | 632 | CHECK(ok == false) 633 | 634 | session:stop() 635 | end 636 | 637 | end) 638 | 639 | TEST("reliability tests", function() 640 | 641 | local store = skilift.store { 642 | name = "test", 643 | migrations = {}, 644 | default_data = function() 645 | return { 646 | money = 0 647 | } 648 | end 649 | } 650 | 651 | local function increment_money(data: PlayerData, n: number) 652 | data.money += n 653 | return data 654 | end 655 | 656 | store:action(increment_money) 657 | store:transaction(increment_money, "increment-money") 658 | 659 | local datastore = ds.DataStoreService:GetDataStore("test") 660 | 661 | do CASE "obtain data after request failure" 662 | datastore:SetAsync("test-failure", { pending_transactions = {}, migrations_performed = {}, data = { money = 1000 } }) 663 | 664 | ds.simulate_outage(true) 665 | local session = store:load("test-failure") 666 | local down = true 667 | 668 | session:updated(function(data) 669 | CHECK(data.money == (if down then 0 else 1000)) 670 | end) 671 | 672 | task.step(100) 673 | task.step(100) 674 | ds.simulate_outage(false) 675 | down = false 676 | task.step(100) 677 | 678 | session:stop() 679 | end 680 | 681 | do CASE "concurrent writing to same key" 682 | datastore:SetAsync("test-concurrent", { pending_transactions = {}, migrations_performed = {}, data = { money = 1000 } }) 683 | 684 | local session = store:load("test-concurrent") 685 | local expected_money = 1000 686 | 687 | 688 | session:updated(function(data) 689 | CHECK(data.money == expected_money) 690 | end) 691 | 692 | expected_money += 10 693 | session:patch(increment_money, 10) 694 | 695 | task.step(100) 696 | datastore:SetAsync("test-concurrent", { pending_transactions = {}, migrations_performed = {}, data = { money = 10000 } }) 697 | expected_money = 10000 698 | task.step(100) 699 | datastore:SetAsync("test-concurrent", { pending_transactions = {}, migrations_performed = {}, data = { money = 3000 } }) 700 | expected_money += 10 701 | session:patch(increment_money, 10) 702 | expected_money += 10 703 | session:patch(increment_money, 10) 704 | expected_money += 10 705 | session:patch(increment_money, 10) 706 | expected_money += 10 707 | session:patch(increment_money, 10) 708 | expected_money = 3040 709 | task.step(100) 710 | session:stop() 711 | end 712 | 713 | do CASE "transaction failure write transaction fail" 714 | 715 | local session_a = store:load("session-a") 716 | local session_b = store:load("session-b") 717 | local session_c = store:load("session-c") 718 | 719 | ds.requests.freeze() 720 | 721 | local thread = coroutine.create(function() 722 | local ok = skilift.transaction( 723 | function() 724 | session_a:patch(increment_money, 15) 725 | session_b:patch(increment_money, -10) 726 | session_c:patch(increment_money, -5) 727 | print("done") 728 | end 729 | ) 730 | 731 | print("aaa") 732 | CHECK(ok == false) 733 | end) 734 | 735 | coroutine.resume(thread) 736 | 737 | ds.requests.pop() 738 | ds.requests.pop() 739 | ds.deny_requests(1) 740 | ds.requests.unfreeze() 741 | task.step(1) 742 | 743 | end 744 | 745 | do CASE "transaction failure write session 3 fail" 746 | 747 | local session_a = store:load("session-a") 748 | local session_b = store:load("session-b") 749 | local session_c = store:load("session-c") 750 | 751 | ds.requests.freeze() 752 | 753 | local thread = coroutine.create(function() 754 | local ok = skilift.transaction( 755 | function() 756 | session_a:patch(increment_money, 15) 757 | session_b:patch(increment_money, -10) 758 | session_c:patch(increment_money, -5) 759 | print("done") 760 | end 761 | ) 762 | 763 | print("aaa") 764 | CHECK(ok == false) 765 | end) 766 | 767 | coroutine.resume(thread) 768 | 769 | ds.requests.pop() 770 | ds.requests.pop() 771 | ds.deny_requests(1) 772 | ds.requests.pop() 773 | ds.requests.pop() 774 | ds.requests.pop() 775 | task.step(1) 776 | ds.requests.unfreeze() 777 | 778 | end 779 | 780 | end) 781 | 782 | FINISH() --------------------------------------------------------------------------------