mirror of
https://github.com/littensy/charm.git
synced 2026-10-23 05:46:27 +00:00
refactor!: create monorepo
This commit is contained in:
parent
b038bf1f70
commit
0416717f6c
68 changed files with 488 additions and 416 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,6 +1,10 @@
|
||||||
/node_modules
|
/node_modules
|
||||||
/Packages
|
/devpackages
|
||||||
/DevPackages
|
/packages/**/node_modules
|
||||||
|
/packages/**/packages
|
||||||
|
/packages/**/wally.lock
|
||||||
|
/packages/**/README.md
|
||||||
|
/packages/**/LICENSE.md
|
||||||
/sourcemap.json
|
/sourcemap.json
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
wally install
|
|
||||||
rojo sourcemap dev.project.json --output sourcemap.json
|
|
||||||
wally-package-types --sourcemap sourcemap.json Packages
|
|
||||||
wally-package-types --sourcemap sourcemap.json DevPackages
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
--!nocheck
|
|
||||||
|
|
||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
||||||
|
|
||||||
local Packages = ReplicatedStorage.Packages
|
|
||||||
local DevPackages = ReplicatedStorage.DevPackages
|
|
||||||
|
|
||||||
local TestEZ = require(DevPackages.TestEZ)
|
|
||||||
|
|
||||||
TestEZ.TestBootstrap:run({ Packages.charm.__tests__ }, TestEZ.Reporters.TextReporter)
|
|
||||||
|
|
@ -6,14 +6,25 @@
|
||||||
"$className": "ReplicatedStorage",
|
"$className": "ReplicatedStorage",
|
||||||
"DevPackages": {
|
"DevPackages": {
|
||||||
"$path": "DevPackages",
|
"$path": "DevPackages",
|
||||||
"Charm": { "$path": "src" }
|
"Charm": {
|
||||||
},
|
"$path": "packages/charm"
|
||||||
"Packages": { "$path": "Packages" }
|
},
|
||||||
|
"CharmSync": {
|
||||||
|
"$path": "packages/charm-sync"
|
||||||
|
},
|
||||||
|
"ReactCharm": {
|
||||||
|
"$path": "packages/react"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"TestService": {
|
"TestService": {
|
||||||
"$className": "TestService",
|
"$className": "TestService",
|
||||||
"$properties": { "ExecuteWithStudioRun": true },
|
"$properties": {
|
||||||
"spec": { "$path": "bin/spec.server.luau" }
|
"ExecuteWithStudioRun": true
|
||||||
|
},
|
||||||
|
"Tests": {
|
||||||
|
"$path": "tests"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
package.json
42
package.json
|
|
@ -1,39 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "@rbxts/charm",
|
"private": true,
|
||||||
"version": "0.7.6",
|
|
||||||
"description": "Atomic state management for Roblox",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "sh bin/check.sh",
|
"lint": "sh bin/check.sh",
|
||||||
"wally": "sh bin/install.sh"
|
"wally:install": "pnpm recursive run wally:install && rojo sourcemap dev.project.json --output sourcemap.json"
|
||||||
},
|
|
||||||
"main": "src/init.lua",
|
|
||||||
"types": "src/index.d.ts",
|
|
||||||
"keywords": [
|
|
||||||
"roblox",
|
|
||||||
"typescript",
|
|
||||||
"roblox-ts",
|
|
||||||
"state"
|
|
||||||
],
|
|
||||||
"author": "littensy",
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/littensy/charm"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/littensy/charm/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/littensy/charm",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"!src/__tests__"
|
|
||||||
],
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rbxts/compiler-types": "3.0.0-types.0",
|
"@rbxts/compiler-types": "3.0.0-types.0",
|
||||||
"@rbxts/react": "^0.4.0",
|
|
||||||
"@rbxts/types": "^1.0.809",
|
"@rbxts/types": "^1.0.809",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
|
@ -42,15 +14,7 @@
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-roblox-ts": "^0.0.36",
|
"eslint-plugin-roblox-ts": "^0.0.36",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"roblox-ts": "3.0.0",
|
"roblox-ts": "^3.0.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@rbxts/react": "*"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@rbxts/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
packages/charm-sync/default.project.json
Normal file
6
packages/charm-sync/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "charm-sync",
|
||||||
|
"tree": {
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/charm-sync/package.json
Normal file
16
packages/charm-sync/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "@rbxts/charm-sync",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Sync server state with clients using Charm",
|
||||||
|
"repository": "littensy/charm",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/init.luau",
|
||||||
|
"types": "src/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
|
"wally:install": "wally install"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rbxts/charm": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
local store = require(script.Parent.Parent.store)
|
local Charm = require(script.Parent.Parent.Charm)
|
||||||
local types = require(script.Parent.Parent.types)
|
|
||||||
type Atom<T> = types.Atom<T>
|
|
||||||
type SyncPayload = types.SyncPayload
|
|
||||||
local patch = require(script.Parent.patch)
|
local patch = require(script.Parent.patch)
|
||||||
|
local types = require(script.Parent.types)
|
||||||
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
type ClientOptions = {
|
type ClientOptions = {
|
||||||
--[=[
|
--[=[
|
||||||
The atoms to synchronize with the server.
|
The atoms to synchronize with the server.
|
||||||
]=]
|
]=]
|
||||||
atoms: { [string]: Atom<any> },
|
atoms: { [string]: Charm.Atom<any> },
|
||||||
--[=[
|
--[=[
|
||||||
Whether to ignore patches sent before the client has been hydrated.
|
Whether to ignore patches sent before the client has been hydrated.
|
||||||
Default is `true`.
|
Default is `true`.
|
||||||
|
|
@ -52,7 +51,7 @@ local function client(options: ClientOptions): ClientSyncer
|
||||||
|
|
||||||
hydrated = true
|
hydrated = true
|
||||||
|
|
||||||
store.batch(function()
|
Charm.batch(function()
|
||||||
for key, state in next, payload.data do
|
for key, state in next, payload.data do
|
||||||
local atom = atoms[key]
|
local atom = atoms[key]
|
||||||
|
|
||||||
163
packages/charm-sync/src/index.d.ts
vendored
Normal file
163
packages/charm-sync/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { Atom, Molecule } from "@rbxts/charm";
|
||||||
|
|
||||||
|
export = CharmSync;
|
||||||
|
export as namespace CharmSync;
|
||||||
|
|
||||||
|
type Cleanup = () => void;
|
||||||
|
|
||||||
|
declare namespace CharmSync {
|
||||||
|
type AtomMap = Record<string, Atom<any>>;
|
||||||
|
|
||||||
|
type MoleculeMap = Record<string, Molecule<any>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively infers the type of the state produced by a map of molecules.
|
||||||
|
*/
|
||||||
|
type StateOfMap<T> = {
|
||||||
|
[P in keyof T]: T[P] extends Molecule<infer State> ? State : never;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A special value that denotes the absence of a value. Used to represent
|
||||||
|
* removed values in patches.
|
||||||
|
*/
|
||||||
|
interface None {
|
||||||
|
readonly __none: "__none";
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaybeNone<T> = undefined extends T ? None : never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a `ClientSyncer` object that receives patches from the server and
|
||||||
|
* applies them to the local state.
|
||||||
|
*
|
||||||
|
* @client
|
||||||
|
* @param options The atoms to synchronize with the server.
|
||||||
|
* @returns A `ClientSyncer` object.
|
||||||
|
*/
|
||||||
|
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a `ServerSyncer` object that sends patches to the client and
|
||||||
|
* hydrates the client's state.
|
||||||
|
*
|
||||||
|
* @server
|
||||||
|
* @param options The atoms to synchronize with the client.
|
||||||
|
* @returns A `ServerSyncer` object.
|
||||||
|
*/
|
||||||
|
function server<Molecules extends MoleculeMap>(options: ServerOptions<Molecules>): ServerSyncer<Molecules>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||||
|
* removed from the state when the patch is applied.
|
||||||
|
*
|
||||||
|
* @param value The value to check.
|
||||||
|
* @returns `true` if the value is `None`, otherwise `false`.
|
||||||
|
*/
|
||||||
|
function isNone(value: unknown): value is None;
|
||||||
|
|
||||||
|
type DataTypes = {
|
||||||
|
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type that should not be made partial in patches.
|
||||||
|
*/
|
||||||
|
type DataType = DataTypes[keyof DataTypes];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A partial patch that can be applied to the state to update it. Represents
|
||||||
|
* the difference between the current state and the next state.
|
||||||
|
*
|
||||||
|
* If a value was removed, it is replaced with `None`. This can be checked
|
||||||
|
* using the `sync.isNone` function.
|
||||||
|
*/
|
||||||
|
type SyncPatch<State> =
|
||||||
|
| MaybeNone<State>
|
||||||
|
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
|
||||||
|
? ReadonlyMap<K, SyncPatch<V> | None>
|
||||||
|
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||||
|
? ReadonlyMap<T, true | None>
|
||||||
|
: State extends readonly (infer T)[]
|
||||||
|
? readonly (SyncPatch<T> | None | undefined)[]
|
||||||
|
: State extends DataType
|
||||||
|
? State
|
||||||
|
: State extends object
|
||||||
|
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
|
||||||
|
: State);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A payload that can be sent from the server to the client to synchronize
|
||||||
|
* state between the two.
|
||||||
|
*/
|
||||||
|
type SyncPayload<Molecules extends MoleculeMap> =
|
||||||
|
| { type: "init"; data: StateOfMap<Molecules> }
|
||||||
|
| { type: "patch"; data: SyncPatch<StateOfMap<Molecules>> };
|
||||||
|
|
||||||
|
interface ClientOptions<Atoms extends AtomMap> {
|
||||||
|
/**
|
||||||
|
* The atoms to synchronize with the server.
|
||||||
|
*/
|
||||||
|
atoms: Atoms;
|
||||||
|
/**
|
||||||
|
* Whether to ignore patches sent before the client has been hydrated.
|
||||||
|
* Defaults to `true`.
|
||||||
|
*/
|
||||||
|
ignoreUnhydrated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerOptions<Molecules extends MoleculeMap> {
|
||||||
|
/**
|
||||||
|
* The atoms to synchronize with the client.
|
||||||
|
*/
|
||||||
|
atoms: Molecules;
|
||||||
|
/**
|
||||||
|
* The interval at which to send patches to the client, in seconds.
|
||||||
|
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||||
|
* negative value to disable automatic syncing.
|
||||||
|
*/
|
||||||
|
interval?: number;
|
||||||
|
/**
|
||||||
|
* Whether the history of state changes since the client's last update
|
||||||
|
* should be preserved. This is useful for values that change multiple times
|
||||||
|
* per frame, where each individual change is important. Defaults to `false`.
|
||||||
|
*
|
||||||
|
* If `true`, the broadcaster will send a list of payloads to the client
|
||||||
|
* instead of a single payload. The client will apply each payload in order
|
||||||
|
* to reconstruct the state's changes over time.
|
||||||
|
*/
|
||||||
|
preserveHistory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientSyncer<Atoms extends AtomMap> {
|
||||||
|
/**
|
||||||
|
* Applies a patch or initializes the state of the atoms with the given
|
||||||
|
* payload from the server.
|
||||||
|
*
|
||||||
|
* @param payloads The patches or hydration payloads to apply.
|
||||||
|
*/
|
||||||
|
sync(...payloads: SyncPayload<Atoms>[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerSyncer<Molecules extends MoleculeMap> {
|
||||||
|
/**
|
||||||
|
* Sets up a subscription to each atom that schedules a patch to be sent to
|
||||||
|
* the client whenever the state changes. When a change occurs, the `callback`
|
||||||
|
* is called with the player and the payloads to send.
|
||||||
|
*
|
||||||
|
* Note that a `payload` object should not be mutated. If you need to modify
|
||||||
|
* a payload, apply the changes to a copy of the object.
|
||||||
|
*
|
||||||
|
* @param callback The function to call when the state changes.
|
||||||
|
* @returns A cleanup function that unsubscribes all listeners.
|
||||||
|
*/
|
||||||
|
connect(callback: (player: Player, ...payloads: SyncPayload<Molecules>[]) => void): Cleanup;
|
||||||
|
/**
|
||||||
|
* Hydrates the client's state with the server's state. This should be
|
||||||
|
* called when a player joins the game and requires the server's state.
|
||||||
|
*
|
||||||
|
* @param player The player to hydrate.
|
||||||
|
*/
|
||||||
|
hydrate(player: Player): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
local client = require(script.client)
|
local client = require(script.client)
|
||||||
local patch = require(script.patch)
|
local patch = require(script.patch)
|
||||||
local server = require(script.server)
|
local server = require(script.server)
|
||||||
|
local types = require(script.types)
|
||||||
|
|
||||||
|
export type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
--[=[
|
--[=[
|
||||||
Synchronizes state between the client and server. The server sends patches
|
Synchronizes state between the client and server. The server sends patches
|
||||||
|
|
@ -7,7 +7,7 @@ local RunService = game:GetService("RunService")
|
||||||
@param seconds The interval in seconds.
|
@param seconds The interval in seconds.
|
||||||
@return A function that cancels the interval.
|
@return A function that cancels the interval.
|
||||||
]=]
|
]=]
|
||||||
local function setInterval(callback: () -> (), seconds: number): () -> ()
|
local function interval(callback: () -> (), seconds: number): () -> ()
|
||||||
if seconds < 0 then
|
if seconds < 0 then
|
||||||
return function() end
|
return function() end
|
||||||
end
|
end
|
||||||
|
|
@ -27,4 +27,4 @@ local function setInterval(callback: () -> (), seconds: number): () -> ()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return setInterval
|
return interval
|
||||||
|
|
@ -4,7 +4,7 @@ local validate = require(script.Parent.validate)
|
||||||
A special symbol that denotes the absence of a value. Used to represent
|
A special symbol that denotes the absence of a value. Used to represent
|
||||||
deleted values in patches.
|
deleted values in patches.
|
||||||
]=]
|
]=]
|
||||||
local NONE = { __none = "__none" }
|
local None = { __none = "__none" }
|
||||||
|
|
||||||
local function isNone(value: any): boolean
|
local function isNone(value: any): boolean
|
||||||
return type(value) == "table" and value.__none == "__none"
|
return type(value) == "table" and value.__none == "__none"
|
||||||
|
|
@ -46,7 +46,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any })
|
||||||
if previous == current then
|
if previous == current then
|
||||||
patches[key] = nil
|
patches[key] = nil
|
||||||
elseif current == nil then
|
elseif current == nil then
|
||||||
patches[key] = NONE
|
patches[key] = None
|
||||||
elseif type(previous) == "table" and type(current) == "table" then
|
elseif type(previous) == "table" and type(current) == "table" then
|
||||||
patches[key] = diff(previous, current)
|
patches[key] = diff(previous, current)
|
||||||
end
|
end
|
||||||
|
|
@ -97,7 +97,7 @@ local function apply(state: any, patches: any): any
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
NONE = NONE,
|
NONE = None,
|
||||||
isNone = isNone,
|
isNone = isNone,
|
||||||
diff = diff,
|
diff = diff,
|
||||||
apply = apply,
|
apply = apply,
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
local Players = game:GetService("Players")
|
local Players = game:GetService("Players")
|
||||||
|
|
||||||
local subscribe = require(script.Parent.Parent.subscribe)
|
local Charm = require(script.Parent.Parent.Charm)
|
||||||
local types = require(script.Parent.Parent.types)
|
local interval = require(script.Parent.interval)
|
||||||
type Atom<T> = types.Atom<T>
|
|
||||||
type SyncPayload = types.SyncPayload
|
|
||||||
local setInterval = require(script.Parent.Parent.utils.setInterval)
|
|
||||||
local patch = require(script.Parent.patch)
|
local patch = require(script.Parent.patch)
|
||||||
|
local types = require(script.Parent.types)
|
||||||
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
type ServerOptions = {
|
type ServerOptions = {
|
||||||
--[=[
|
--[=[
|
||||||
The atoms to synchronize with the client.
|
The atoms to synchronize with the client.
|
||||||
]=]
|
]=]
|
||||||
atoms: { [string]: Atom<any> },
|
atoms: { [string]: Charm.Atom<any> },
|
||||||
--[=[
|
--[=[
|
||||||
The interval at which to send patches to the client, in seconds.
|
The interval at which to send patches to the client, in seconds.
|
||||||
Defaults to `0` (patches are sent up to once per frame). Set to a
|
Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||||
|
|
@ -103,7 +102,7 @@ local function server(options: ServerOptions): ServerSyncer
|
||||||
-- Populate the snapshot with the initial state of each atom.
|
-- Populate the snapshot with the initial state of each atom.
|
||||||
-- Subscribe to each atom and update the state when it changes.
|
-- Subscribe to each atom and update the state when it changes.
|
||||||
for key, atom in next, options.atoms do
|
for key, atom in next, options.atoms do
|
||||||
subscriptions[key] = subscribe(atom, function(current, previous)
|
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
|
||||||
pushSnapshot(key, current, previous)
|
pushSnapshot(key, current, previous)
|
||||||
changed = true
|
changed = true
|
||||||
end)
|
end)
|
||||||
|
|
@ -111,7 +110,7 @@ local function server(options: ServerOptions): ServerSyncer
|
||||||
|
|
||||||
snapshots[1] = createSnapshot()
|
snapshots[1] = createSnapshot()
|
||||||
|
|
||||||
local disconnect = setInterval(function()
|
local disconnect = interval(function()
|
||||||
if not changed then
|
if not changed then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -187,13 +186,13 @@ local function server(options: ServerOptions): ServerSyncer
|
||||||
|
|
||||||
-- Subscribe to each atom and update the state when one changes.
|
-- Subscribe to each atom and update the state when one changes.
|
||||||
for key, atom in next, options.atoms do
|
for key, atom in next, options.atoms do
|
||||||
subscriptions[key] = subscribe(atom, function(state)
|
subscriptions[key] = Charm.subscribe(atom, function(state)
|
||||||
currentSnapshot[key] = state
|
currentSnapshot[key] = state
|
||||||
changed = true
|
changed = true
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local disconnect = setInterval(function()
|
local disconnect = interval(function()
|
||||||
if not changed then
|
if not changed then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
10
packages/charm-sync/src/types.luau
Normal file
10
packages/charm-sync/src/types.luau
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
--[=[
|
||||||
|
A payload that can be sent from the server to the client to synchronize
|
||||||
|
state between the two.
|
||||||
|
]=]
|
||||||
|
export type SyncPayload = {
|
||||||
|
type: "patch" | "init",
|
||||||
|
data: { [any]: any },
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
local store = require(script.Parent.Parent.store)
|
local Charm = require(script.Parent.Parent.Charm)
|
||||||
|
|
||||||
local SAFE_KEYS = { string = true, number = true }
|
local SAFE_KEYS = { string = true, number = true }
|
||||||
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true }
|
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true }
|
||||||
|
|
@ -47,7 +47,7 @@ local function validate(value: any, key: any)
|
||||||
elseif typeOfValue == "table" then
|
elseif typeOfValue == "table" then
|
||||||
if getmetatable(value) ~= nil then
|
if getmetatable(value) ~= nil then
|
||||||
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
||||||
elseif store.isAtom(value) then
|
elseif Charm.isAtom(value) then
|
||||||
error(`Cannot sync nested atoms! Got atom at key '{key}'`)
|
error(`Cannot sync nested atoms! Got atom at key '{key}'`)
|
||||||
elseif isUnsafeTable(value) then
|
elseif isUnsafeTable(value) then
|
||||||
error(
|
error(
|
||||||
7
packages/charm-sync/wally.toml
Normal file
7
packages/charm-sync/wally.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "littensy/charm-sync"
|
||||||
|
version = "0.1.0"
|
||||||
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
|
realm = "shared"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
3
packages/charm/.luaurc
Normal file
3
packages/charm/.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"languageMode": "strict"
|
||||||
|
}
|
||||||
13
packages/charm/package.json
Normal file
13
packages/charm/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "@rbxts/charm",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"description": "An atomic state management library for Roblox",
|
||||||
|
"repository": "littensy/charm",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/init.luau",
|
||||||
|
"types": "src/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
|
"wally:install": "wally install"
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/index.d.ts → packages/charm/src/index.d.ts
vendored
172
src/index.d.ts → packages/charm/src/index.d.ts
vendored
|
|
@ -42,16 +42,6 @@ declare namespace Charm {
|
||||||
*/
|
*/
|
||||||
type StateOf<T> = T extends Molecule<infer State> ? State : never;
|
type StateOf<T> = T extends Molecule<infer State> ? State : never;
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively infers the type of the state produced by a map of molecules.
|
|
||||||
*/
|
|
||||||
type StateOfMap<T> = {
|
|
||||||
[P in keyof T]: T[P] extends Molecule<infer State> ? State : never;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AtomMap = Record<string, Atom<any>>;
|
|
||||||
type MoleculeMap = Record<string, Molecule<any>>;
|
|
||||||
|
|
||||||
interface AtomOptions<State> {
|
interface AtomOptions<State> {
|
||||||
/**
|
/**
|
||||||
* A function that determines whether the state has changed. By default,
|
* A function that determines whether the state has changed. By default,
|
||||||
|
|
@ -189,166 +179,4 @@ declare namespace Charm {
|
||||||
molecule: Molecule<AnyMap<K0, V0>>,
|
molecule: Molecule<AnyMap<K0, V0>>,
|
||||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||||
): Molecule<ReadonlyMap<K1, V1>>;
|
): Molecule<ReadonlyMap<K1, V1>>;
|
||||||
|
|
||||||
/**
|
|
||||||
* A hook that subscribes to changes in the given atom or molecule. The
|
|
||||||
* component is re-rendered whenever the state changes.
|
|
||||||
*
|
|
||||||
* If the `dependencies` array is provided, the subscription to the atom or
|
|
||||||
* molecule is re-created whenever the dependencies change. Otherwise, the
|
|
||||||
* subscription is created once when the component is mounted.
|
|
||||||
*
|
|
||||||
* @param molecule The atom or molecule to subscribe to.
|
|
||||||
* @param dependencies An array of values that the subscription depends on.
|
|
||||||
* @returns The current state.
|
|
||||||
*/
|
|
||||||
function useAtom<State>(molecule: Molecule<State>, dependencies?: unknown[]): State;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronizes state between the client and server. The server sends patches
|
|
||||||
* to the client, which applies them to its local state.
|
|
||||||
*/
|
|
||||||
const sync: {
|
|
||||||
/**
|
|
||||||
* Creates a `ClientSyncer` object that receives patches from the server and
|
|
||||||
* applies them to the local state.
|
|
||||||
*
|
|
||||||
* @client
|
|
||||||
* @param options The atoms to synchronize with the server.
|
|
||||||
* @returns A `ClientSyncer` object.
|
|
||||||
*/
|
|
||||||
client: <Atoms extends AtomMap>(options: ClientOptions<Atoms>) => ClientSyncer<Atoms>;
|
|
||||||
/**
|
|
||||||
* Creates a `ServerSyncer` object that sends patches to the client and
|
|
||||||
* hydrates the client's state.
|
|
||||||
*
|
|
||||||
* @server
|
|
||||||
* @param options The atoms to synchronize with the client.
|
|
||||||
* @returns A `ServerSyncer` object.
|
|
||||||
*/
|
|
||||||
server: <Molecules extends MoleculeMap>(options: ServerOptions<Molecules>) => ServerSyncer<Molecules>;
|
|
||||||
/**
|
|
||||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
|
||||||
* removed from the state when the patch is applied.
|
|
||||||
*
|
|
||||||
* @param value The value to check.
|
|
||||||
* @returns `true` if the value is `None`, otherwise `false`.
|
|
||||||
*/
|
|
||||||
isNone: (value: unknown) => value is None;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A special value that denotes the absence of a value. Used to represent
|
|
||||||
* removed values in patches.
|
|
||||||
*/
|
|
||||||
interface None {
|
|
||||||
readonly __none: "__none";
|
|
||||||
}
|
|
||||||
|
|
||||||
type MaybeNone<T> = undefined extends T ? None : never;
|
|
||||||
|
|
||||||
type DataTypes = {
|
|
||||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A type that should not be made partial in patches.
|
|
||||||
*/
|
|
||||||
type DataType = DataTypes[keyof DataTypes];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A partial patch that can be applied to the state to update it. Represents
|
|
||||||
* the difference between the current state and the next state.
|
|
||||||
*
|
|
||||||
* If a value was removed, it is replaced with `None`. This can be checked
|
|
||||||
* using the `sync.isNone` function.
|
|
||||||
*/
|
|
||||||
type SyncPatch<State> =
|
|
||||||
| MaybeNone<State>
|
|
||||||
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
|
|
||||||
? ReadonlyMap<K, SyncPatch<V> | None>
|
|
||||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
|
||||||
? ReadonlyMap<T, true | None>
|
|
||||||
: State extends readonly (infer T)[]
|
|
||||||
? readonly (SyncPatch<T> | None | undefined)[]
|
|
||||||
: State extends DataType
|
|
||||||
? State
|
|
||||||
: State extends object
|
|
||||||
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
|
|
||||||
: State);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A payload that can be sent from the server to the client to synchronize
|
|
||||||
* state between the two.
|
|
||||||
*/
|
|
||||||
type SyncPayload<Molecules extends MoleculeMap> =
|
|
||||||
| { type: "init"; data: StateOfMap<Molecules> }
|
|
||||||
| { type: "patch"; data: SyncPatch<StateOfMap<Molecules>> };
|
|
||||||
|
|
||||||
interface ClientOptions<Atoms extends AtomMap> {
|
|
||||||
/**
|
|
||||||
* The atoms to synchronize with the server.
|
|
||||||
*/
|
|
||||||
atoms: Atoms;
|
|
||||||
/**
|
|
||||||
* Whether to ignore patches sent before the client has been hydrated.
|
|
||||||
* Defaults to `true`.
|
|
||||||
*/
|
|
||||||
ignoreUnhydrated?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerOptions<Molecules extends MoleculeMap> {
|
|
||||||
/**
|
|
||||||
* The atoms to synchronize with the client.
|
|
||||||
*/
|
|
||||||
atoms: Molecules;
|
|
||||||
/**
|
|
||||||
* The interval at which to send patches to the client, in seconds.
|
|
||||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
|
||||||
* negative value to disable automatic syncing.
|
|
||||||
*/
|
|
||||||
interval?: number;
|
|
||||||
/**
|
|
||||||
* Whether the history of state changes since the client's last update
|
|
||||||
* should be preserved. This is useful for values that change multiple times
|
|
||||||
* per frame, where each individual change is important. Defaults to `false`.
|
|
||||||
*
|
|
||||||
* If `true`, the broadcaster will send a list of payloads to the client
|
|
||||||
* instead of a single payload. The client will apply each payload in order
|
|
||||||
* to reconstruct the state's changes over time.
|
|
||||||
*/
|
|
||||||
preserveHistory?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClientSyncer<Atoms extends AtomMap> {
|
|
||||||
/**
|
|
||||||
* Applies a patch or initializes the state of the atoms with the given
|
|
||||||
* payload from the server.
|
|
||||||
*
|
|
||||||
* @param payloads The patches or hydration payloads to apply.
|
|
||||||
*/
|
|
||||||
sync(...payloads: SyncPayload<Atoms>[]): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerSyncer<Molecules extends MoleculeMap> {
|
|
||||||
/**
|
|
||||||
* Sets up a subscription to each atom that schedules a patch to be sent to
|
|
||||||
* the client whenever the state changes. When a change occurs, the `callback`
|
|
||||||
* is called with the player and the payloads to send.
|
|
||||||
*
|
|
||||||
* Note that a `payload` object should not be mutated. If you need to modify
|
|
||||||
* a payload, apply the changes to a copy of the object.
|
|
||||||
*
|
|
||||||
* @param callback The function to call when the state changes.
|
|
||||||
* @returns A cleanup function that unsubscribes all listeners.
|
|
||||||
*/
|
|
||||||
connect(callback: (player: Player, ...payloads: SyncPayload<Molecules>[]) => void): Cleanup;
|
|
||||||
/**
|
|
||||||
* Hydrates the client's state with the server's state. This should be
|
|
||||||
* called when a player joins the game and requires the server's state.
|
|
||||||
*
|
|
||||||
* @param player The player to hydrate.
|
|
||||||
*/
|
|
||||||
hydrate(player: Player): void;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,10 +5,8 @@ local computed = require(script.computed)
|
||||||
local effect = require(script.effect)
|
local effect = require(script.effect)
|
||||||
local mapped = require(script.mapped)
|
local mapped = require(script.mapped)
|
||||||
local observe = require(script.observe)
|
local observe = require(script.observe)
|
||||||
local useAtom = require(script.react.useAtom)
|
|
||||||
local store = require(script.store)
|
local store = require(script.store)
|
||||||
local subscribe = require(script.subscribe)
|
local subscribe = require(script.subscribe)
|
||||||
local sync = require(script.sync)
|
|
||||||
local types = require(script.types)
|
local types = require(script.types)
|
||||||
|
|
||||||
export type Atom<State> = types.Atom<State>
|
export type Atom<State> = types.Atom<State>
|
||||||
|
|
@ -21,12 +19,10 @@ return {
|
||||||
effect = effect,
|
effect = effect,
|
||||||
mapped = mapped,
|
mapped = mapped,
|
||||||
observe = observe,
|
observe = observe,
|
||||||
useAtom = useAtom,
|
subscribe = subscribe,
|
||||||
batch = store.batch,
|
batch = store.batch,
|
||||||
capture = store.capture,
|
capture = store.capture,
|
||||||
isAtom = store.isAtom,
|
isAtom = store.isAtom,
|
||||||
notify = store.notify,
|
notify = store.notify,
|
||||||
peek = store.peek,
|
peek = store.peek,
|
||||||
subscribe = subscribe,
|
|
||||||
sync = sync,
|
|
||||||
}
|
}
|
||||||
7
packages/charm/wally.toml
Normal file
7
packages/charm/wally.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "littensy/charm"
|
||||||
|
version = "0.8.0"
|
||||||
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
|
realm = "shared"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
3
packages/react/.luaurc
Normal file
3
packages/react/.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"languageMode": "strict"
|
||||||
|
}
|
||||||
6
packages/react/default.project.json
Normal file
6
packages/react/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "react-charm",
|
||||||
|
"tree": {
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/react/package.json
Normal file
16
packages/react/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "@rbxts/react-charm",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "React bindings for Charm",
|
||||||
|
"repository": "littensy/charm",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/init.luau",
|
||||||
|
"types": "src/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
|
"wally:install": "wally install"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rbxts/charm": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/react/src/index.d.ts
vendored
Normal file
15
packages/react/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Molecule } from "@rbxts/charm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook that subscribes to changes in the given atom or molecule. The
|
||||||
|
* component is re-rendered whenever the state changes.
|
||||||
|
*
|
||||||
|
* If the `dependencies` array is provided, the subscription to the atom or
|
||||||
|
* molecule is re-created whenever the dependencies change. Otherwise, the
|
||||||
|
* subscription is created once when the component is mounted.
|
||||||
|
*
|
||||||
|
* @param molecule The atom or molecule to subscribe to.
|
||||||
|
* @param dependencies An array of values that the subscription depends on.
|
||||||
|
* @returns The current state.
|
||||||
|
*/
|
||||||
|
export function useAtom<State>(molecule: Molecule<State>, dependencies?: unknown[]): State;
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
local requireReact = require(script.Parent.Parent.modules.React)
|
local Charm = require(script.Parent.Charm)
|
||||||
local subscribe = require(script.Parent.Parent.subscribe)
|
local React = require(script.Parent.React)
|
||||||
local types = require(script.Parent.Parent.types)
|
|
||||||
type Molecule<T> = types.Molecule<T>
|
|
||||||
|
|
||||||
--[=[
|
--[=[
|
||||||
A hook that subscribes to changes in the given atom or molecule. The
|
A hook that subscribes to changes in the given atom or molecule. The
|
||||||
|
|
@ -15,17 +13,17 @@ type Molecule<T> = types.Molecule<T>
|
||||||
@param dependencies An array of values that the subscription depends on.
|
@param dependencies An array of values that the subscription depends on.
|
||||||
@return The current state.
|
@return The current state.
|
||||||
]=]
|
]=]
|
||||||
local function useAtom<State>(molecule: Molecule<State>, dependencies: { any }?): State
|
local function useAtom<State>(molecule: Charm.Molecule<State>, dependencies: { any }?): State
|
||||||
local React = requireReact()
|
|
||||||
|
|
||||||
local state, setState = React.useState(molecule)
|
local state, setState = React.useState(molecule)
|
||||||
|
|
||||||
React.useEffect(function()
|
React.useEffect(function()
|
||||||
setState(molecule())
|
setState(molecule())
|
||||||
return subscribe(molecule, setState)
|
return Charm.subscribe(molecule, setState)
|
||||||
end, dependencies or {})
|
end, dependencies or {})
|
||||||
|
|
||||||
return state
|
return state
|
||||||
end
|
end
|
||||||
|
|
||||||
return useAtom
|
return {
|
||||||
|
useAtom = useAtom,
|
||||||
|
}
|
||||||
7
packages/react/wally.toml
Normal file
7
packages/react/wally.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "littensy/react-charm"
|
||||||
|
version = "0.1.0"
|
||||||
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
|
realm = "shared"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
|
|
@ -11,9 +11,6 @@ importers:
|
||||||
'@rbxts/compiler-types':
|
'@rbxts/compiler-types':
|
||||||
specifier: 3.0.0-types.0
|
specifier: 3.0.0-types.0
|
||||||
version: 3.0.0-types.0
|
version: 3.0.0-types.0
|
||||||
'@rbxts/react':
|
|
||||||
specifier: ^0.4.0
|
|
||||||
version: 0.4.0
|
|
||||||
'@rbxts/types':
|
'@rbxts/types':
|
||||||
specifier: ^1.0.809
|
specifier: ^1.0.809
|
||||||
version: 1.0.809
|
version: 1.0.809
|
||||||
|
|
@ -39,12 +36,26 @@ importers:
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
roblox-ts:
|
roblox-ts:
|
||||||
specifier: 3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.6.2
|
specifier: ^5.6.2
|
||||||
version: 5.6.2
|
version: 5.6.2
|
||||||
|
|
||||||
|
packages/charm: {}
|
||||||
|
|
||||||
|
packages/charm-sync:
|
||||||
|
dependencies:
|
||||||
|
'@rbxts/charm':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../charm
|
||||||
|
|
||||||
|
packages/react:
|
||||||
|
dependencies:
|
||||||
|
'@rbxts/charm':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../charm
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@eslint-community/eslint-utils@4.4.0':
|
'@eslint-community/eslint-utils@4.4.0':
|
||||||
|
|
@ -97,12 +108,6 @@ packages:
|
||||||
'@rbxts/compiler-types@3.0.0-types.0':
|
'@rbxts/compiler-types@3.0.0-types.0':
|
||||||
resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==}
|
resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==}
|
||||||
|
|
||||||
'@rbxts/react-vendor@0.4.0':
|
|
||||||
resolution: {integrity: sha512-roWs9FmR7CQDK1N6gE/36tiCBQRl8v1S0pUJD2Ly3ZMVvfjAk2gixrkqGcKFGtXQQq6r4dTAtHduAeZJBd640g==}
|
|
||||||
|
|
||||||
'@rbxts/react@0.4.0':
|
|
||||||
resolution: {integrity: sha512-yhqXzfH5YpwfZyiWipQMfQsI//2sC86Jqn2rMkP+l1y4CfhCvoYxpaI5qS7Xum9F+DsBHiwJNeUxm2V2LzXW0A==}
|
|
||||||
|
|
||||||
'@rbxts/types@1.0.809':
|
'@rbxts/types@1.0.809':
|
||||||
resolution: {integrity: sha512-COmNdE5VKq44tU1Sqbj0b70qDcUpBY1Uu5BhNb25v1yJATwcoVjsmYTyaxmRO4EVdqOATjOUBU2jbxZa4K83uw==}
|
resolution: {integrity: sha512-COmNdE5VKq44tU1Sqbj0b70qDcUpBY1Uu5BhNb25v1yJATwcoVjsmYTyaxmRO4EVdqOATjOUBU2jbxZa4K83uw==}
|
||||||
|
|
||||||
|
|
@ -879,12 +884,6 @@ snapshots:
|
||||||
|
|
||||||
'@rbxts/compiler-types@3.0.0-types.0': {}
|
'@rbxts/compiler-types@3.0.0-types.0': {}
|
||||||
|
|
||||||
'@rbxts/react-vendor@0.4.0': {}
|
|
||||||
|
|
||||||
'@rbxts/react@0.4.0':
|
|
||||||
dependencies:
|
|
||||||
'@rbxts/react-vendor': 0.4.0
|
|
||||||
|
|
||||||
'@rbxts/types@1.0.809': {}
|
'@rbxts/types@1.0.809': {}
|
||||||
|
|
||||||
'@roblox-ts/luau-ast@2.0.0': {}
|
'@roblox-ts/luau-ast@2.0.0': {}
|
||||||
|
|
|
||||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- packages/*
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
return function()
|
|
||||||
local previous = _G.__DEV__
|
|
||||||
|
|
||||||
beforeAll(function()
|
|
||||||
_G.__DEV__ = false -- prevent React errors
|
|
||||||
end)
|
|
||||||
|
|
||||||
afterAll(function()
|
|
||||||
_G.__DEV__ = previous
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
local Package = script.Parent.Parent
|
|
||||||
|
|
||||||
if _G[script] then
|
|
||||||
return _G[script].Promise
|
|
||||||
end
|
|
||||||
|
|
||||||
assert(Package.Parent:FindFirstChild("Promise"), `Could not find Promise in {Package.Parent:GetFullName()}`)
|
|
||||||
|
|
||||||
return require(Package.Parent.Promise)
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
local Package = script.Parent.Parent
|
|
||||||
local Index = script:FindFirstAncestor("_Index")
|
|
||||||
local Packages = if Package.Parent:FindFirstChild("React")
|
|
||||||
then Package.Parent
|
|
||||||
elseif Index then Index.Parent
|
|
||||||
else Package.Parent.Parent:FindFirstChild("ReactLua")
|
|
||||||
|
|
||||||
type React = typeof(require(Package.Parent.React))
|
|
||||||
|
|
||||||
local React: React
|
|
||||||
|
|
||||||
return function()
|
|
||||||
if not React then
|
|
||||||
React = Packages and require(Packages.React) :: React
|
|
||||||
end
|
|
||||||
return React
|
|
||||||
end
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
local Package = script.Parent.Parent
|
|
||||||
local Packages = if Package.Parent:FindFirstChild("ReactRoblox")
|
|
||||||
then Package.Parent
|
|
||||||
else Package.Parent.Parent:FindFirstChild("ReactLua")
|
|
||||||
|
|
||||||
type ReactRoblox = typeof(require(Package.Parent.ReactRoblox))
|
|
||||||
|
|
||||||
-- It's safe to require this module since it's only used in unit tests.
|
|
||||||
return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
roots = ["ReplicatedStorage/DevPackages/Charm/__tests__"]
|
roots = ["TestService/Tests"]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
-- This file is for use by Benchmarker
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
-- https://boatbomber.itch.io/benchmarker
|
|
||||||
|
|
||||||
local Package = game:GetService("ReplicatedStorage").DevPackages.Charm
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local computed = Charm.computed
|
||||||
local atom = require(Package.atom)
|
local atom = Charm.atom
|
||||||
local computed = require(Package.computed)
|
|
||||||
|
|
||||||
local function parameterGenerator()
|
local function parameterGenerator()
|
||||||
return atom(0)
|
return atom(0)
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
local atom = require(script.Parent.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local subscribe = require(script.Parent.Parent.Parent.subscribe)
|
|
||||||
local sync = require(script.Parent.Parent.Parent.sync)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local subscribe = Charm.subscribe
|
||||||
|
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||||
|
local client = CharmSync.client
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("syncs state payload", function()
|
it("syncs state payload", function()
|
||||||
local a = atom(1)
|
local a = atom(1)
|
||||||
local client = sync.client({ atoms = { a = a } })
|
local client = client({ atoms = { a = a } })
|
||||||
|
|
||||||
client:sync({
|
client:sync({
|
||||||
type = "init",
|
type = "init",
|
||||||
|
|
@ -18,7 +22,7 @@ return function()
|
||||||
it("syncs state patch", function()
|
it("syncs state patch", function()
|
||||||
local a = atom({ b = 1, c = 2 })
|
local a = atom({ b = 1, c = 2 })
|
||||||
local previous = a()
|
local previous = a()
|
||||||
local client = sync.client({ atoms = { a = a } })
|
local client = client({ atoms = { a = a } })
|
||||||
|
|
||||||
client:sync({
|
client:sync({
|
||||||
type = "init",
|
type = "init",
|
||||||
|
|
@ -39,7 +43,7 @@ return function()
|
||||||
|
|
||||||
it("fixes sparse array patches", function()
|
it("fixes sparse array patches", function()
|
||||||
local a = atom({ 1 })
|
local a = atom({ 1 })
|
||||||
local client = sync.client({ atoms = { a = a } })
|
local client = client({ atoms = { a = a } })
|
||||||
|
|
||||||
client:sync({
|
client:sync({
|
||||||
type = "init",
|
type = "init",
|
||||||
|
|
@ -60,7 +64,7 @@ return function()
|
||||||
|
|
||||||
it("applies multiple patches", function()
|
it("applies multiple patches", function()
|
||||||
local a = atom(1)
|
local a = atom(1)
|
||||||
local client = sync.client({ atoms = { a = a } })
|
local client = client({ atoms = { a = a } })
|
||||||
local calls = {}
|
local calls = {}
|
||||||
|
|
||||||
subscribe(a, function(value)
|
subscribe(a, function(value)
|
||||||
|
|
@ -88,7 +92,7 @@ return function()
|
||||||
it("batches patches", function()
|
it("batches patches", function()
|
||||||
local a = atom(0)
|
local a = atom(0)
|
||||||
local b = atom(0)
|
local b = atom(0)
|
||||||
local client = sync.client({ atoms = { a = a, b = b } })
|
local client = client({ atoms = { a = a, b = b } })
|
||||||
local states = {}
|
local states = {}
|
||||||
|
|
||||||
subscribe(a, function()
|
subscribe(a, function()
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
local store = require(script.Parent.Parent.store)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
local previous = _G.__DEV__
|
local previous = _G.__DEV__
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
local HttpService = game:GetService("HttpService")
|
local HttpService = game:GetService("HttpService")
|
||||||
local patch = require(script.Parent.Parent.Parent.sync.patch)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
local function serdes(value: { [any]: any })
|
local function serdes(value: { [any]: any })
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
local atom = require(script.Parent.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local sync = require(script.Parent.Parent.Parent.sync)
|
|
||||||
local patch = require(script.Parent.Parent.Parent.sync.patch)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
local types = require(script.Parent.Parent.Parent.types)
|
local atom = Charm.atom
|
||||||
|
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
|
||||||
|
type SyncPayload = CharmSync.SyncPayload
|
||||||
|
local server = CharmSync.server
|
||||||
|
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
local target: Player = {} :: any
|
local target: Player = {} :: any
|
||||||
|
|
||||||
it("sends state to new players", function()
|
it("sends state to new players", function()
|
||||||
local a = atom(1)
|
local a = atom(1)
|
||||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
local server = server({ atoms = { a = a }, interval = -1 })
|
||||||
local player: Player, payload: types.SyncPayload
|
local player: Player, payload: SyncPayload
|
||||||
|
|
||||||
local disconnect = server:connect(function(...)
|
local disconnect = server:connect(function(...)
|
||||||
player, payload = ...
|
player, payload = ...
|
||||||
|
|
@ -26,8 +30,8 @@ return function()
|
||||||
|
|
||||||
it("sends state patches", function()
|
it("sends state patches", function()
|
||||||
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
|
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
|
||||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
local server = server({ atoms = { a = a }, interval = -1 })
|
||||||
local player: Player, payload: types.SyncPayload
|
local player: Player, payload: SyncPayload
|
||||||
|
|
||||||
local disconnect = server:connect(function(...)
|
local disconnect = server:connect(function(...)
|
||||||
player, payload = ...
|
player, payload = ...
|
||||||
|
|
@ -54,9 +58,9 @@ return function()
|
||||||
it("preserves history", function()
|
it("preserves history", function()
|
||||||
local a = atom(1)
|
local a = atom(1)
|
||||||
local b = atom(1)
|
local b = atom(1)
|
||||||
local payloads: { types.SyncPayload }
|
local payloads: { SyncPayload }
|
||||||
|
|
||||||
local server = sync.server({
|
local server = server({
|
||||||
atoms = { a = a, b = b },
|
atoms = { a = a, b = b },
|
||||||
interval = -1,
|
interval = -1,
|
||||||
preserveHistory = true,
|
preserveHistory = true,
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
local atom = require(script.Parent.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local validate = require(script.Parent.Parent.Parent.sync.validate)
|
|
||||||
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("validates inputs", function()
|
it("validates inputs", function()
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local store = require(script.Parent.Parent.store)
|
|
||||||
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("updates state", function()
|
it("updates state", function()
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local computed = require(script.Parent.Parent.computed)
|
|
||||||
local effect = require(script.Parent.Parent.effect)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
local store = require(script.Parent.Parent.store)
|
local atom = Charm.atom
|
||||||
local subscribe = require(script.Parent.Parent.subscribe)
|
local computed = Charm.computed
|
||||||
|
local subscribe = Charm.subscribe
|
||||||
|
local effect = Charm.effect
|
||||||
|
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("batches updates", function()
|
it("batches updates", function()
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local store = require(script.Parent.Parent.store)
|
|
||||||
local subscribe = require(script.Parent.Parent.subscribe)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local subscribe = Charm.subscribe
|
||||||
|
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("gracefully handles yielding", function()
|
it("gracefully handles yielding", function()
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local computed = require(script.Parent.Parent.computed)
|
|
||||||
local store = require(script.Parent.Parent.store)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local computed = Charm.computed
|
||||||
|
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||||
local collect = require(script.Parent.Parent.utils.collect)
|
local collect = require(script.Parent.Parent.utils.collect)
|
||||||
local count = require(script.Parent.Parent.utils.count)
|
local count = require(script.Parent.Parent.utils.count)
|
||||||
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local effect = require(script.Parent.Parent.effect)
|
|
||||||
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local effect = Charm.effect
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("runs on state change", function()
|
it("runs on state change", function()
|
||||||
22
tests/charm/init.spec.luau
Normal file
22
tests/charm/init.spec.luau
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local store = Charm.store
|
||||||
|
|
||||||
|
return function()
|
||||||
|
local previous = _G.__DEV__
|
||||||
|
|
||||||
|
beforeAll(function()
|
||||||
|
_G.__DEV__ = true
|
||||||
|
end)
|
||||||
|
|
||||||
|
afterAll(function()
|
||||||
|
_G.__DEV__ = previous
|
||||||
|
end)
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
table.clear(store.listeners :: any)
|
||||||
|
table.clear(store.capturing.stack)
|
||||||
|
store.capturing.index = 0
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local mapped = require(script.Parent.Parent.mapped)
|
|
||||||
local observe = require(script.Parent.Parent.observe)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
local store = require(script.Parent.Parent.store)
|
local atom = Charm.atom
|
||||||
|
local mapped = Charm.mapped
|
||||||
|
local observe = Charm.observe
|
||||||
|
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("returns mapped atoms", function()
|
it("returns mapped atoms", function()
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local observe = require(script.Parent.Parent.observe)
|
|
||||||
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local observe = Charm.observe
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("observes lifetime", function()
|
it("observes lifetime", function()
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local store = require(script.Parent.Parent.store)
|
|
||||||
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local capture = Charm.capture
|
||||||
|
local peek = Charm.peek
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("calls an atom without capturing it", function()
|
it("calls an atom without capturing it", function()
|
||||||
local a, b, c = atom(1), atom(1), atom(1)
|
local a, b, c = atom(1), atom(1), atom(1)
|
||||||
local deps, result = store.capture(function()
|
local deps, result = capture(function()
|
||||||
return a() + store.peek(b) + c()
|
return a() + peek(b) + c()
|
||||||
end)
|
end)
|
||||||
expect(deps[a]).to.be.ok()
|
expect(deps[a]).to.be.ok()
|
||||||
expect(deps[b]).to.never.be.ok()
|
expect(deps[b]).to.never.be.ok()
|
||||||
|
|
@ -14,8 +18,8 @@ return function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("passes through arguments", function()
|
it("passes through arguments", function()
|
||||||
expect(store.peek(1)).to.equal(1)
|
expect(peek(1)).to.equal(1)
|
||||||
expect(store.peek(function()
|
expect(peek(function()
|
||||||
return 1
|
return 1
|
||||||
end)).to.equal(1)
|
end)).to.equal(1)
|
||||||
end)
|
end)
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
local atom = require(script.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local computed = require(script.Parent.Parent.computed)
|
|
||||||
local store = require(script.Parent.Parent.store)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
local subscribe = require(script.Parent.Parent.subscribe)
|
local atom = Charm.atom
|
||||||
|
local computed = Charm.computed
|
||||||
|
local subscribe = Charm.subscribe
|
||||||
|
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||||
local collect = require(script.Parent.Parent.utils.collect)
|
local collect = require(script.Parent.Parent.utils.collect)
|
||||||
local count = require(script.Parent.Parent.utils.count)
|
local count = require(script.Parent.Parent.utils.count)
|
||||||
|
|
||||||
5
tests/init.server.luau
Normal file
5
tests/init.server.luau
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
|
||||||
|
|
||||||
|
TestEZ.TestBootstrap:run({ script }, TestEZ.Reporters.TextReporter)
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
local atom = require(script.Parent.Parent.Parent.atom)
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local useAtom = require(script.Parent.Parent.Parent.react.useAtom)
|
|
||||||
local renderHook = require(script.Parent.helpers.renderHook)
|
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||||
|
local atom = Charm.atom
|
||||||
|
local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm)
|
||||||
|
local useAtom = ReactCharm.useAtom
|
||||||
|
local renderHook = require(script.Parent.Parent.utils.renderHook)
|
||||||
|
|
||||||
return function()
|
return function()
|
||||||
it("tracks atom state", function()
|
it("tracks atom state", function()
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||||
|
|
||||||
local Package = script:FindFirstAncestor("__tests__").Parent
|
local React = require(ReplicatedStorage.DevPackages.React)
|
||||||
|
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
|
||||||
local React = require(Package.modules.React)()
|
|
||||||
local ReactRoblox = require(Package.modules.ReactRoblox)
|
|
||||||
|
|
||||||
type RenderedHook<Props, Result> = {
|
type RenderedHook<Props, Result> = {
|
||||||
result: Result,
|
result: Result,
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"typeRoots": ["node_modules/@rbxts"],
|
"typeRoots": ["node_modules/@rbxts"],
|
||||||
|
|
||||||
// configurable
|
// configurable
|
||||||
"rootDir": "src",
|
"rootDir": "packages",
|
||||||
"outDir": "out",
|
"outDir": "out",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"tsBuildInfoFile": "out/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "out/tsconfig.tsbuildinfo",
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
registry = "test"
|
registry = "test"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "evaera/promise"
|
|
||||||
version = "4.0.0"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsdotlua/boolean"
|
name = "jsdotlua/boolean"
|
||||||
version = "1.2.6"
|
version = "1.2.6"
|
||||||
|
|
@ -95,7 +90,7 @@ dependencies = [["collections", "jsdotlua/collections@1.2.6"]]
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "littensy/charm"
|
name = "littensy/charm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [["Promise", "evaera/promise@4.0.0"], ["React", "jsdotlua/react@17.1.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.1.0"], ["TestEZ", "roblox/testez@0.4.1"]]
|
dependencies = [["React", "jsdotlua/react@17.1.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.1.0"], ["TestEZ", "roblox/testez@0.4.1"]]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roblox/testez"
|
name = "roblox/testez"
|
||||||
|
|
|
||||||
12
wally.toml
12
wally.toml
|
|
@ -1,18 +1,8 @@
|
||||||
[package]
|
[package]
|
||||||
name = "littensy/charm"
|
name = "littensy/charm"
|
||||||
description = "Atomic state management for Roblox"
|
version = "0.1.0"
|
||||||
version = "0.7.6"
|
|
||||||
license = "MIT"
|
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
exclude = ["**"]
|
|
||||||
include = ["src", "src/**", "default.project.json", "wally.toml", "LICENSE.md"]
|
|
||||||
|
|
||||||
[place]
|
|
||||||
shared-packages = "game.ReplicatedStorage.Packages"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
Promise = "evaera/promise@4.0.0"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
React = "jsdotlua/react@17.1.0"
|
React = "jsdotlua/react@17.1.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue