Builtin Library
README.md
data_schema
Pure schema engine for the typed-data system. A contract's
schema.yaml declares fields + constraints; this module parses the
decoded declaration, merges extends chains, applies defaults, and
validates value tables. Pure Luau — no FFI, no VFS reads — callers
decode the YAML themselves (e.g. via modules/yaml) and inject
asset/contract resolver callbacks.
Exports
DataSchema.parseSchema(raw) -> (Schema?, { string })DataSchema.mergeChain(chain) -> ({ [string]: FieldSpec }?, { string })DataSchema.applyDefaults(merged, values) -> { [string]: any }DataSchema.validateValues(merged, values, resolvers) -> { Violation }
Types:
FieldSpec = { type: string, required: boolean?, default: any?, min: number?, max: number?, enum: { any }?, assetType: string?, contract: string?, items: FieldSpec?, fields: { [string]: FieldSpec }? }Schema = { extends: string?, fields: { [string]: FieldSpec } }Violation = { path: string, message: string }Resolvers = { assetExists: (identity: string, assetType: string) -> boolean, contractSatisfied: (identity: string, contract: string) -> (boolean, string?) }
Field types
number, string, boolean, vec3, color, assetRef, dataRef,
array, struct.
min/maxapply only tonumberfields, and must be numbers.enumapplies only to scalar field types (number/string/boolean) and must be a non-empty array — the value must equal one of the listed entries.requiredmust be a boolean (YAML's bareyesdecodes as a string and is a parse-time problem).assetReffields require anassetType; validation callsresolvers.assetExists(identity, assetType).dataReffields require acontract; validation callsresolvers.contractSatisfied(identity, contract).arrayfields require anitemsspec (a nested FieldSpec every element must satisfy).structfields require afieldsmap (nested subfield specs); struct values reject unknown keys the same way top-level values do.
Usage
local DS = require("@builtin::modules.data_schema")
-- 1. Parse each contract's declaration in the extends chain, root
-- parent first.
local itemSchema, p1 = DS.parseSchema(Yaml.decode(itemYaml))
local weaponSchema, p2 = DS.parseSchema(Yaml.decode(weaponYaml))
-- 2. Merge the chain into one field map. A child redeclaring a field
-- the parent already declared is a problem.
local merged, mergeProblems = DS.mergeChain({ itemSchema, weaponSchema })
-- 3. Fill in schema defaults for a value table that omits some fields.
local filled = DS.applyDefaults(merged, rawValues)
-- 4. Validate a value table — missing required fields, constraint
-- breaches, unknown fields, and ref fields via the injected
-- resolvers.
local violations = DS.validateValues(merged, filled, {
assetExists = function(identity, assetType)
return asset.exists(identity, assetType)
end,
contractSatisfied = function(identity, contract)
return dataInstanceSatisfies(identity, contract)
end,
})
Notes
- Inputs must be acyclic tables — YAML-decoded data always is.
- Every parsed FieldSpec, Schema, and merged field map is frozen
(
table.freeze): cache them freely; any accidental mutation raises at the mutation site. - A schema declaration's top-level keys are
extendsandfieldsonly; anything else (e.g. anextend:typo) is a parse problem.$schemais the one accepted-and-ignored tooling key. parseSchemarejects a default value that violates its own field spec at declaration time (checked withresolvers = nil, so ref fields are only shape-checked, not resolved, against a default).mergeChainorder matters: pass the chain root-parent-first, derived-contract-last. Redeclaring an inherited field is a merge problem, not a silent override — shared shape comes from the parent, new shape from the child. A nil chain hole (e.g. a failedparseSchemaresult passed straight in) is a problem entry, and a non-Schema entry (false, a string, ...) raises at the call boundary — never a silent truncation.applyDefaultsnever mutates its inputs; it returns a new table with deep copies of both the explicit values and any filled-in defaults. Defaults fill recursively: struct values gain their subfield defaults and array elements gain their item defaults, at every depth.validateValuestreats a field with a schema default as never "missing" even whenrequired = trueand the value table omits it —applyDefaultsfills that gap, at every depth.
Editing values through Field controls
A data instance's values edit through the same typed controls as asset settings.
DataSchema.toFields(merged)gives one Field descriptor per contract field: a number with both bounds isField.range, with one or noneField.number; a string or number withenumisField.enum(a number enum's members as strings); boolean, vec3, color, assetRef, dataRef, array and struct map toField.bool,Field.vec3,Field.color,Field.assetRef,Field.dataRef,Field.listandField.struct. Every descriptor isNoSync, and a struct's fields are built in name order.DataSchema.toFieldValues(merged, values)turns the values a data file holds into the forms those controls read: a colour"#rrggbb[aa]"into{ r, g, b, a }, a vec3[x, y, z]into{ x, y, z }, a reference's identity into{ __ref = guid }, a number enum member into its string. Arrays and structs convert item by item.DataSchema.toFieldValues(merged, values, base)resolves a reference within its field's category (assetTypefor an assetRef,datafor a dataRef), a~form againstbase(the data file's path), and a guid by guid.DataSchema.fromFieldValues(merged, values)is the inverse, for any subset of fields.DataSchema.editedValues(merged, stored, edited, base)gives the top-level values a control edit writes: each named field as stored, with only the leaves the edit changed replaced, each in the form the file held it in. Untouched values keep their stored value and form (a colour held as numbers above 1, a map-form vector, keys the contract does not declare). In a list, each item the edit leaves alone is found by what it holds, so removing or reordering items keeps every other item as stored; an added item takes the form the list's items are stored in.