npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hexium-softworks/playerdataservice

v0.1.0

Published

Schema-driven Nevermore player data wrapper with migrations, validation, and owner-only replication.

Readme

PlayerDataService

Schema-driven player data for Nevermore Roblox games.

PlayerDataService is a high-level wrapper around Nevermore's PlayerDataStoreService. It does not replace Nevermore's datastore system. Instead, it uses Nevermore for the hard persistence work, then adds a small, game-agnostic layer for schemas, migrations, validated writes, active profile objects, and owner-only replication.

What It Adds

  • Template-first schemas with simple field helpers.
  • Reconciliation for missing or structurally invalid saved data.
  • Versioned migrations before data is exposed to game code.
  • Runtime validation for every write.
  • Active server profile objects with path APIs and accessor trees.
  • Read-only client access to replicated owner-visible fields.
  • Owner-only replication through ReplicationService.
  • Isolation inside one configured Nevermore datastore namespace.

What Nevermore Still Owns

This package intentionally keeps Nevermore in charge of persistence mechanics. Internally, the server service loads data with:

serviceBag:GetService(PlayerDataStoreService):PromiseDataStore(player)

That means Nevermore still owns:

  • the underlying Roblox datastore connection
  • per-player datastore sessions
  • session locking behavior
  • autosave behavior
  • save retries
  • shutdown save handling
  • datastore mocks via PlayerDataStoreService:SetRobloxDataStore()

PlayerDataService only works inside the configured namespace of the returned Nevermore datastore. By default, that namespace is Profile.

{
	Profile = {
		Data = {
			Currency = {
				Coins = 100,
			},
		},

		Metadata = {
			SchemaVersion = 2,
		},
	},

	Settings = {
		-- owned by another package
	},
}

Normal profile mutations write through Nevermore DataStoreStage substores, so changing Currency.Coins stores only that nested value. Whole-namespace writes are reserved for the initial migration/reconciliation commit.

Install

pnpm add @hexium-softworks/playerdataservice

The package is designed for Nevermore ServiceBag projects and expects Nevermore datastore packages plus @hexium-softworks/replicationservice.

Quick Start

Create a schema module:

local Schema = require("PlayerDataSchema")

return Schema.define({
	Version = 1,

	Template = {
		Currency = Schema.Owner({
			Coins = Schema.Int(0, { Min = 0 }),
			Gems = Schema.Int(0, { Min = 0 }),
		}),

		Progression = Schema.Owner({
			Level = Schema.Int(1, { Min = 1 }),
			Experience = Schema.Int(0, { Min = 0 }),
		}),

		Internal = {
			LastReceiptId = "",
			PurchaseHistory = {},
		},
	},
})

Only Version and Template are required. Internal is not a special key and is not required by the package; it is just a conventional place in this example for private server-owned data such as purchase receipts. You can name that section Server, Private, Receipts, split it across multiple tables, or omit it entirely.

Configure the server service before ServiceBag:Start():

local require = require(script.Parent.loader).load(script)

local PlayerDataService = require("PlayerDataService")

function GameDataConfiguration:Init(serviceBag)
	self._playerData = serviceBag:GetService(PlayerDataService)
	self._playerData:SetSchema(require("GamePlayerDataSchema"))
	self._playerData:SetNamespace("Profile")
end

Use the loaded profile on the server:

local profile = playerDataService:GetProfile(player)
if not profile then
	return
end

profile:Increment("Currency.Coins", 100)
profile.Data.Progression.Level:Set(2)

Read replicated owner-visible data on the client:

playerDataClient.Data.Currency.Coins:Observe(function(coins)
	coinsLabel.Text = tostring(coins)
end)

Key Concepts

Schema

The schema is the contract for the data this package owns. It defines:

  • Version: the current saved data version.
  • Template: the default shape for valid profile data.
  • Migrations: optional versioned transforms for older saved data.
  • validation rules for individual fields.
  • which fields may replicate to the owning player.

Plain template values are persisted and server-only by default. A field must be wrapped in Schema.Owner(...) to replicate to the owning client.

The schema does not need to contain any package-specific top-level data key. Currency, Progression, Internal, Inventory, and similar names are all ordinary game-owned fields. The reserved datastore keys are outside your template: PlayerDataService stores the template data under {Namespace}.Data and stores schema metadata under {Namespace}.Metadata.

Profile

A profile is the active server object for one loaded player. It is not a raw table. It is a safe wrapper that validates changes, persists changes into the Nevermore data stage, emits observers, and patches replicated state.

Path API

Every profile supports string paths:

profile:Get("Currency.Coins")
profile:Set("Currency.Coins", 50)
profile:Increment("Currency.Coins", 5)

Paths are useful for dynamic systems such as inventory items, quest ids, and other data where static accessor names are awkward.

Accessor Tree

For fixed schema fields, profiles and clients also expose safe accessors:

profile.Data.Currency.Coins:Increment(5)
playerDataClient.Data.Currency.Coins:Observe(function(coins)
	print(coins)
end)

Accessors are wrappers over the same path API. They are not raw mutable data tables. Field names that collide with accessor methods, such as Get, Set, Observe, or Increment, are rejected during schema validation.

Reconciliation

When saved data loads, the package reconciles it against the current template:

  • valid existing values are preserved
  • missing fields are added from the template
  • unknown saved fields are preserved for compatibility
  • arrays are treated as complete values
  • structurally incompatible values are logged and replaced with template values

New writes to unknown schema paths are rejected.

Migrations

Migrations run before reconciliation. A migration with key [2] transforms data from version 1 into version 2.

Migrations = {
	[2] = function(data)
		data.Currency = data.Currency or {
			Coins = data.Coins or 0,
			Gems = 0,
		}
		data.Coins = nil
		return data
	end,
}

Migrations run in ascending order until the saved profile reaches the current schema Version. If a required migration is missing or throws, loading fails and the stored schema version is not advanced.

Replication

Replication is opt-in. Only owner-visible fields are projected:

Currency = Schema.Owner({
	Coins = Schema.Int(0, { Min = 0 }),
	Gems = Schema.Int(0, { Min = 0 }),
}),

Internal = {
	LastReceiptId = "",
},

The owning client can read Currency, but cannot see Internal.

Replication patches are computed from a filtered projection, not the raw profile table. This prevents private sibling fields from leaking when a parent table is mutated.

Failure Policy

Load, migration, or validation failures do not create replacement profiles over possibly valid saved data. By default, the player is kicked with a generic message. Games can install their own policy:

playerDataService:SetLoadFailureHandler(function(player, failure)
	player:Kick("Your data could not be loaded. Please rejoin.")
end)

Schema Interface

Schema.define(config)

Defines a schema.

Schema.define({
	Version = 2,
	Template = {},
	Migrations = {},
})

Version must be a positive integer. Template must be a table. Migrations is optional.

Required fields:

  • Version: used to decide which migrations must run for saved profiles.
  • Template: the data shape this package owns.

Optional fields:

  • Migrations: versioned functions that transform older saved data.

There are no required template children. This is valid:

return Schema.define({
	Version = 1,
	Template = {
		Coins = Schema.Owner(Schema.Int(0, { Min = 0 })),
	},
})

So is this:

return Schema.define({
	Version = 1,
	Template = {
		Inventory = {},
		Receipts = {},
	},
})

Naming rules:

  • root Metadata is reserved because the package stores schema metadata there
  • field names cannot contain .
  • field names cannot be empty strings
  • accessor method names such as Get, Set, Observe, and Increment cannot be used as schema field names

Plain Values

Plain values become persisted defaults and are server-only.

Template = {
	Internal = {
		LastReceiptId = "",
		PurchaseHistory = {},
	},
}

Internal in this example is only a normal field name. Plain values are private because they are not wrapped in Schema.Owner(...), not because they are under a field named Internal.

Valid datastore-safe values are supported: strings, numbers, booleans, arrays, dictionaries, and nested tables.

Important defaults:

  • plain fields are persisted
  • plain fields are server-only
  • Schema.Owner(...) opts a field or subtree into owner replication
  • Schema.ServerOnly(...) opts a nested field back out of owner replication
  • unknown saved fields are preserved when loading but cannot be newly written

Schema.Owner(value)

Marks a field or subtree as visible to the owning player.

Currency = Schema.Owner({
	Coins = Schema.Int(0, { Min = 0 }),
})

All descendants inherit owner visibility unless a nested wrapper changes the visibility.

Schema.ServerOnly(value)

Explicitly marks a field or subtree as private server data.

Stats = Schema.Owner({
	Level = Schema.Int(1, { Min = 1 }),
	SecretRoll = Schema.ServerOnly(0),
})

Plain values are already server-only, so this is mainly useful inside an owner-visible subtree.

Schema.Int(default, options?)

Declares an integer field.

Coins = Schema.Int(0, { Min = 0, Max = 999999 })

Options:

  • Min: minimum allowed value
  • Max: maximum allowed value

Schema.Number(default, options?)

Declares a finite number field.

WalkSpeedMultiplier = Schema.Number(1, { Min = 0.5, Max = 2 })

Options:

  • Min: minimum allowed value
  • Max: maximum allowed value

Schema.String(default, options?)

Declares a string field.

DisplayTitle = Schema.String("", { MaxLength = 24 })

Options:

  • MaxLength: maximum string length

Schema.Boolean(default)

Declares a boolean field.

TutorialComplete = Schema.Boolean(false)

Schema.Enum(default, members)

Declares a string field restricted to a known set of values.

Rarity = Schema.Enum("Common", { "Common", "Rare", "Epic" })

The default must be one of the members.

Schema.Optional(inner)

Declares a leaf field that may be absent.

Nickname = Schema.Optional(Schema.String("", { MaxLength = 20 }))

Optional fields are not inserted into new profiles by default. Setting an optional field to nil deletes it. Delete is only allowed for optional fields; required fields cannot be deleted.

Schema.Dynamic(factory)

Declares a field whose default is produced per profile.

CreatedAt = Schema.Dynamic(function()
	return os.time()
end)

The factory runs for new profiles and for missing dynamic fields during load. The returned value must be datastore-safe.

Schema.Check(default, checker)

Declares a field with a custom checker.

FiveStepValue = Schema.Check(0, function(value)
	return typeof(value) == "number" and value % 5 == 0, "Expected a multiple of 5"
end)

The checker receives the candidate value and returns:

boolean, string?

This works with checker libraries such as Osyris t if your game imports them. t is intentionally not a dependency of this package.

Raw Schema Compatibility

The older raw schema shape is still supported:

return {
	Version = 1,

	Template = {
		Currency = {
			Coins = 0,
		},
	},

	Replication = {
		Owner = {
			"Currency.Coins",
		},
	},

	Validators = {
		["Currency.Coins"] = function(value)
			return typeof(value) == "number" and value >= 0
		end,
	},
}

New code should prefer PlayerDataSchema.define(...) because visibility and validation live beside the fields they describe.

Server API

Service

playerDataService:SetSchema(schema)
playerDataService:SetNamespace(namespace)
playerDataService:SetLoadFailureHandler(callback)
playerDataService:SetProfileReleasedHandler(callback)
playerDataService:SetReplicationEnabled(enabled)

playerDataService:IsLoaded(player)
playerDataService:GetProfile(player)
playerDataService:PromiseProfile(player)
playerDataService:ObserveProfile(player)
playerDataService:ObserveLoadedPlayers()

Configuration methods must be called before Start().

Profile

profile:Get(path?)
profile:GetSnapshot()
profile:Set(path, value)
profile:Update(path, callback)
profile:Increment(path, amount?)
profile:Delete(path)
profile:Batch(callback)
profile:Mutate(callback)
profile:Observe(path?)
profile:ObserveSnapshot()
profile:GetPlayer()
profile:IsActive()
profile:PromiseSave()
profile:Destroy()

Get returns cloned data. GetSnapshot and observer payloads return readonly snapshots. Callers cannot mutate the authoritative profile table by editing a returned value.

Use Batch when applying several path changes that should validate and commit together:

profile:Batch(function(transaction)
	transaction:Increment("Currency.Coins", 500)
	transaction:Set("Progression.Level", 2)
end)

Use Mutate when the shape of the change is easier to express against a draft:

profile:Mutate(function(data)
	data.Currency.Coins += 100
	data.Progression.Experience += 25
end)

The draft is validated before it is committed. Unknown new schema paths are rejected.

Client API

PlayerDataServiceClient is read-only and targets the local player's replicated state.

playerDataClient:SetSchema(schema)

playerDataClient:IsReady()
playerDataClient:PromiseReady()
playerDataClient:Get(path?)
playerDataClient:GetSnapshot()
playerDataClient:Observe(path?)
playerDataClient:ObserveSnapshot()
playerDataClient:ObserveSelector(selector)

SetSchema is optional, but useful when you want the client accessor tree to be built from the same owner-visible schema paths as the server. It must be called before Start().

Get and GetSnapshot raise before readiness. Observers may be created before readiness and will emit when the replicated state arrives.

The client has no write API.

Lifecycle

For each player:

  1. Nevermore opens or reuses the player's datastore session.
  2. PlayerDataService reads {Namespace}.Data.
  3. PlayerDataService reads {Namespace}.Metadata.SchemaVersion.
  4. New profiles receive schema defaults.
  5. Existing profiles run migrations.
  6. Data is reconciled against the current template.
  7. Validators run on the completed profile.
  8. The completed data and current schema version are staged into Nevermore.
  9. The active PlayerProfile is exposed on the server.
  10. Owner-visible fields are projected into ReplicationService.
  11. Later server mutations validate, stage nested writes, notify observers, and patch replicated owner-visible data.

When the player leaves, the active profile is released, replicated state is destroyed, and Nevermore continues to handle the datastore session close/save flow.

Current Limits

  • Derived replicated fields are not included yet.
  • Offline profile handles are not included yet.
  • Cross-player or public profile replication is not included yet.
  • Accessor trees are runtime wrappers; generated static accessor types are not included yet.

References

  • Nevermore DataStore: https://quenty.github.io/NevermoreEngine/api/DataStore/
  • Nevermore DataStoreStage: https://quenty.github.io/NevermoreEngine/api/DataStoreStage/
  • Nevermore PlayerDataStoreService: https://quenty.github.io/NevermoreEngine/api/PlayerDataStoreService/
  • Nevermore PlayerDataStoreHandle: https://quenty.github.io/NevermoreEngine/api/PlayerDataStoreHandle/