From 333a86b43fcb041c9b7783739d37b944e4604b1c Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 7 Jul 2024 01:09:57 -0700 Subject: [PATCH] feat: add `sync.isNone`, fix sync payload type (#11) * feat: add `sync.isNone`, fix sync payload type * docs: add `isNone` --- README.md | 31 ++++++++++++++++++++++- src/index.d.ts | 62 ++++++++++++++++++++++++++++++++++----------- src/sync/init.luau | 2 ++ src/sync/patch.luau | 5 ++++ 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d92304d..c421167 100644 --- a/README.md +++ b/README.md @@ -400,7 +400,7 @@ remotes.requestState.connect((player) => { - `server.connect(callback)` registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload. -- `server.hydrate(player)` sends the initial state to a player, calling the callback passed to `connect` with a payload containing the initial state. +- `server.hydrate(player)` sends the initial state to a player. This calls the function passed to `connect` with a payload containing the initial state. #### Caveats @@ -408,6 +408,35 @@ remotes.requestState.connect((player) => { --- +### `sync.isNone(value)` + +State patches represent the _difference_ between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be `nil` in the patch. In these cases, Charm uses the `None` marker to represent a removed value. + +Call `sync.isNone` to check if a value is `None`. + +```ts +import { sync } from "@rbxts/charm"; + +const server = sync.server({ atoms: atomsToSync }); + +server.connect((player, payload) => { + if (sync.isNone(payload.data.todosAtom?.eggs)) { + // 'eggs' will be removed from the client's todo list + } + remotes.syncState.fire(player, payload); +}); +``` + +#### Parameters + +- `value`: Any value. If the value is `None`, `sync.isNone` will return `true`. + +#### Returns + +`sync.isNone` returns a boolean. + +--- + ## 🚀 Examples ### Counter atom diff --git a/src/index.d.ts b/src/index.d.ts index 16dd912..fd5ac0b 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -33,11 +33,24 @@ declare namespace Charm { * A function that depends on one or more atoms and produces a state. Can be * used to derive state from atoms. * - * @template State The type of the state. * @returns The current state. */ type Molecule = () => State; + /** + * Infers the type of the state produced by the given molecule. + */ + type StateOf = T extends Molecule ? State : never; + + /** + * Recursively infers the type of the state produced by a map of molecules. + */ + type StateOfMap = { + [P in keyof T]: T[P] extends Molecule ? State : never; + }; + + type AtomMap = Record>; + interface AtomOptions { /** * A function that determines whether the state has changed. By default, @@ -200,7 +213,7 @@ declare namespace Charm { * @param options The atoms to synchronize with the server. * @returns A `ClientSyncer` object. */ - client: >>(options: ClientOptions) => ClientSyncer; + client: (options: ClientOptions) => ClientSyncer; /** * Creates a `ServerSyncer` object that sends patches to the client and * hydrates the client's state. @@ -209,39 +222,58 @@ declare namespace Charm { * @param options The atoms to synchronize with the client. * @returns A `ServerSyncer` object. */ - server: >>(options: ServerOptions) => ServerSyncer; + server: (options: ServerOptions) => ServerSyncer; + /** + * 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 - * undefined values in patches. + * removed values in patches. */ interface None { readonly __none: "__none"; } - type SyncPatch = { - readonly [P in keyof T]?: (T[P] extends object ? SyncPatch : T[P]) | (T[P] extends undefined ? None : never); + /** + * 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 = { + readonly [P in keyof State]?: + | (State[P] extends object ? SyncPatch : State[P]) + | (undefined extends State[P] ? None : never); }; /** * A payload that can be sent from the server to the client to synchronize * state between the two. */ - type SyncPayload = { type: "init"; data: T } | { type: "patch"; data: SyncPatch }; + type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch> }; - interface ClientOptions>> { + interface ClientOptions { /** * The atoms to synchronize with the server. */ - atoms: T; + atoms: Atoms; } - interface ServerOptions>> { + interface ServerOptions { /** * The atoms to synchronize with the client. */ - atoms: T; + atoms: Atoms; /** * 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 @@ -250,17 +282,17 @@ declare namespace Charm { interval?: number; } - interface ClientSyncer>> { + interface ClientSyncer { /** * Applies a patch or initializes the state of the atoms with the given * payload from the server. * * @param payload The patch or hydration payload to apply. */ - sync(payload: SyncPayload): void; + sync(payload: SyncPayload): void; } - interface ServerSyncer>> { + interface ServerSyncer { /** * 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` @@ -272,7 +304,7 @@ declare namespace Charm { * @param callback The function to call when the state changes. * @returns A cleanup function that unsubscribes all listeners. */ - connect(callback: (player: Player, payload: SyncPayload) => void): Cleanup; + connect(callback: (player: Player, payload: SyncPayload) => 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. diff --git a/src/sync/init.luau b/src/sync/init.luau index b3e4e4c..7a236f4 100644 --- a/src/sync/init.luau +++ b/src/sync/init.luau @@ -1,4 +1,5 @@ local client = require(script.client) +local patch = require(script.patch) local server = require(script.server) --[=[ @@ -8,4 +9,5 @@ local server = require(script.server) return { client = client, server = server, + isNone = patch.isNone, } diff --git a/src/sync/patch.luau b/src/sync/patch.luau index d4c9893..c06ad2b 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -6,6 +6,10 @@ local validate = require(script.Parent.validate) ]=] local NONE = { __none = "__none" } +local function isNone(value: any): boolean + return type(value) == "table" and value.__none == "__none" +end + local function diff(prevState: { [any]: any }, nextState: { [any]: any }) local patches = table.clone(nextState) @@ -62,6 +66,7 @@ end return { NONE = NONE, + isNone = isNone, diff = diff, apply = apply, }