Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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 / max apply only to number fields, and must be numbers.
  • enum applies only to scalar field types (number / string / boolean) and must be a non-empty array — the value must equal one of the listed entries.
  • required must be a boolean (YAML's bare yes decodes as a string and is a parse-time problem).
  • assetRef fields require an assetType; validation calls resolvers.assetExists(identity, assetType).
  • dataRef fields require a contract; validation calls resolvers.contractSatisfied(identity, contract).
  • array fields require an items spec (a nested FieldSpec every element must satisfy).
  • struct fields require a fields map (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 extends and fields only; anything else (e.g. an extend: typo) is a parse problem. $schema is the one accepted-and-ignored tooling key.
  • parseSchema rejects a default value that violates its own field spec at declaration time (checked with resolvers = nil, so ref fields are only shape-checked, not resolved, against a default).
  • mergeChain order 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 failed parseSchema result passed straight in) is a problem entry, and a non-Schema entry (false, a string, ...) raises at the call boundary — never a silent truncation.
  • applyDefaults never 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.
  • validateValues treats a field with a schema default as never "missing" even when required = true and the value table omits it — applyDefaults fills 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 is Field.range, with one or none Field.number; a string or number with enum is Field.enum (a number enum's members as strings); boolean, vec3, color, assetRef, dataRef, array and struct map to Field.bool, Field.vec3, Field.color, Field.assetRef, Field.dataRef, Field.list and Field.struct. Every descriptor is NoSync, 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 (assetType for an assetRef, data for a dataRef), a ~ form against base (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.