greetings
 
You are here:

bbsengine6 Best Practices

Status: canonical. Updated 2026-09-04.

This document collects two rules that bit us in production enough to be worth making explicit:

io.echo and the @variable syntax

io.echo() interprets {token} escape sequences: {restorecursor}, {savecursor}, {promptcolor}, {valuecolor}, etc. For those sequences to be processed, the call must use an f-string. A plain string is passed through verbatim and the escape sequences never get expanded.

Correct

io.echo(f"{{restorecursor}}{{promptcolor}}{prompt}{{valuecolor}}{result}")
io.echo(f"{{labelcolor}}Item: {{valuecolor}}{item.content}{{/all}}\n")

Wrong

io.echo("{restorecursor}{promptcolor}...")        # escape sequences not processed
io.echo(f"{{labelcolor}}Item: {{valuecolor}}{{item.content}}{{/all}}\n")  # braces inside braces

The double-brace / single-brace distinction is the load-bearing part:

Braces Meaning
{{token}} io.echo escape sequence (e.g. {labelcolor})
{varname} Python f-string substitution

The linter rule F541 (f-string without any placeholders) is disabled in the project configuration because io.echo calls like io.echo(f"{{savecursor}}") have no Python placeholders but still must be f-strings for the io.echo layer to see the escape sequences.

JSON handling at the database boundary

Database conversions are handled by bbsengine6.database. Never call json.dumps() before passing data to a database function.

Note: convert_for_jsonb() wraps only the top-level dict/list in Jsonb. Inner dicts/lists are returned as plain Python objects to avoid the Object of type Jsonb is not JSON serializable error that psycopg's dumper raises on nested Jsonb instances. See database.md for the function reference.

The rule

# WRONG - double conversion, creates non-serializable Jsonb object
rec["flags"] = json.dumps(database.convert_for_jsonb(member["flags"]))
database.update(args, table, pk, rec)

# CORRECT - let the database module handle conversion
rec["flags"] = member["flags"]   # keep as dict
database.update(args, table, pk, rec)
# database.update() calls convert_for_jsonb(rec["flags"]) internally

Why this matters

The correct flow:

member dict
  -> buildrec(member)        # keeps dicts as dicts
  -> rec (dict with dicts)
  -> database.update()
       -> convert_for_jsonb(rec["flags"])
       -> wraps dict in psycopg3.Jsonb
  -> cur.execute(sql, [Jsonb(...)])
       -> psycopg3 serializes Jsonb -> JSONB bytes
       -> PostgreSQL stores JSONB

What goes wrong if you call json.dumps upstream:

buildrec() called json.dumps(convert_for_jsonb(dict))
  -> Jsonb({...}) object created
  -> json.dumps() tries to serialize it
  -> ERROR: Object of type Jsonb is not JSON serializable

Layer responsibilities

Application layer (member.py, console/member.py, etc.) is allowed to transform data structures (filter, rename, format fields), convert lists to strings where the schema calls for it, and pass the result to a database function. Application code must not:

Database layer (database.py) is responsible for:

Patterns

Simple values (strings, numbers, booleans) pass through unchanged:

rec = {
    "moniker": "testuser",
    "credits": 500,
    "verified": True,
}
database.update(args, table, pk, rec)

Dict values stay as dicts; the database layer wraps them:

rec = {
    "flags": {
        "APPROVED": {"value": True},
        "VERIFIED": {"value": False},
    }
}
database.update(args, table, pk, rec)

List values stay as lists. The exception is the ui column on engine.__member, which is stored as a comma-separated string and is converted by buildrec():

# input
rec = {"ui": ["telnet", "web", "ssh"]}
# after buildrec()
rec = {"ui": "telnet, web, ssh"}
database.update(args, table, pk, rec)

Nested dicts stay nested:

rec = {
    "settings": {
        "theme": {"dark_mode": True, "color_scheme": "solarized"},
        "notifications": {"email": True, "push": False},
    }
}
database.update(args, table, pk, rec)
# Internally: convert_for_jsonb wraps the top-level dict in Jsonb only;
# inner dicts are plain, so psycopg's dumper can serialize the whole tree.

Function reference

buildrec(member) -- bbsengine6.member

Transforms a member dict for database operations:

input = {
    "moniker": "test",
    "flags": {"APPROVED": {"value": True}},
    "ui": ["telnet", "web"],
    "datecreatedepoch": 1234567,  # removed
}
output = buildrec(input)
# {
#     "moniker": "test",
#     "flags": {"APPROVED": {"value": True}},   # dict, not string
#     "ui": "telnet, web",
# }

database.convert_for_jsonb(value, *, wrap=True)

Wraps Python objects for psycopg3. Top-level dicts/lists are wrapped in Jsonb; inner dicts/lists stay plain. wrap=False is the recursion path used internally; callers should use the default.

dict_value = {"outer": {"inner": 1}}
jsonb_value = database.convert_for_jsonb(dict_value)
type(jsonb_value)                 # psycopg.types.json.Jsonb
type(jsonb_value.obj["outer"])    # dict (plain, not Jsonb)
jsonb_value.obj                  # {'outer': {'inner': 1}}

database.update(args, table, pk, items, ...)

Updates rows; iterates over items, calls convert_for_jsonb() on each value, and executes the resulting SQL. Pass plain Python dicts.

database.update(args, "engine.__member", "test", {"flags": {"APPROVED": True}})
# convert_for_jsonb({"APPROVED": True}) -> Jsonb({"APPROVED": True})
# psycopg3 serializes the Jsonb to JSONB bytes

Debugging checklist

If you see TypeError: Object of type Jsonb is not JSON serializable:

  1. Remove json.dumps() calls from application code before passing to a database function.
  2. Remove pre-emptive convert_for_jsonb() calls in application code; database.update() / database.insert() call it internally.
  3. Replace any hand-rolled Jsonb(Jsonb(...)) with a single Jsonb({...}) over the outer dict, and let convert_for_jsonb handle the wrap.
  4. Confirm buildrec() returns dicts as dicts (not JSON strings, not Jsonb objects).
  5. Confirm database.update() is called with plain dicts.

Related