http-nu 0.17.2

The surprisingly performant, Nushell-scriptable, cross.stream-powered, Datastar-ready HTTP server that fits in your back pocket.
Documentation
# Strip a 2048 xs store down to just the raw played game data, normalized
# to the current topic shape. Operates on a `frames.jsonl` dump (one JSON
# frame per line, the format `xs cat` writes).
#
# KEEPS, rewriting the old topic shape to the new one:
#
#   player.<uuid>.games                      game creation; frame id = game id
#   game.<id>.move      ->  game.move.<id>   a player's move (old shape)
#   game.move.<id>                           a player's move (already current)
#
# Old-shape move frames carry only `{intent, req_id}` in meta -- they
# predate the `user_id` stamp. The current snapshot-actor gates each move
# on `move-authorized` (meta.user_id == the game's owner), so a move with
# no user_id is dropped and the game never rebuilds. The owner is
# unambiguous (the game id IS its player.<uuid>.games frame id), so this
# script backfills `user_id` into any move that lacks one, bringing the
# move meta up to the current schema.
#
# DROPS everything else. None of it is raw game data -- it is either
# derived state or runtime-managed plumbing, and it comes back on its own
# when serve.nu starts:
#
#   game.snapshot.<id> / game.<id>.snapshot
#       Derived. The snapshot-actor rebuilds the full chain (root + every
#       move, with prev links) from the move log on its first boot against
#       a snapshot-less store -- see snapshot-actor.nu's `start: "first"`.
#   <name>.register / .active / .unregistered, game.nu
#       Pre-rename xs lifecycle + module frames. The 0.13 runtime ignores
#       them (ADR 0005); serve.nu re-registers under xs.actor.* /
#       xs.module.* at startup.
#   page.html / base.html / nav.html, session.*, xs.start, bus.*,
#   leaderboard.top, _presence.*, audit.*
#       Page chrome, auth sessions, server-boot markers, and ephemeral
#       UI/derived frames.
#
# Frame ids, meta, hash, and ttl are preserved verbatim; only old-shape
# move topics are rewritten. Kept frames carry no CAS, so there is no
# cas/ to copy.
#
# Workflow. Frame ids MUST be preserved -- the game id is its
# player.<uuid>.games frame id -- so import with `.import` (inserts
# verbatim), never `.append` (mints new ids):
#
#   # 1. dump the source store
#   xs serve ./store-old &
#   xs cat ./store-old/sock | save raw.jsonl
#
#   # 2. strip + normalize
#   nu strip-store.nu raw.jsonl stripped.jsonl
#
#   # 3. import into a fresh store in one shot. `.import` is part of the
#   #    eval command surface (http-nu bundles xs >= 0.13.2); --store opens
#   #    the store directly, no serve/socket needed. One pass over the file,
#   #    not a process per frame.
#   http-nu eval --store ./store-new -c 'open --raw stripped.jsonl | lines | where {|l| ($l | str trim) != ""} | each {|l| $l | .import } | ignore'
#
#   # 4. serve it; the snapshot-actor finds no snapshots, so start: "first"
#   #    replays the move log once and rebuilds every game from scratch.
#   http-nu --services --store ./store-new :3002 examples/2048/serve.nu

def is-move [t: string] {
  ($t | str starts-with "game.move.") or (($t | str starts-with "game.") and ($t | str ends-with ".move"))
}

def is-games [t: string] {
  ($t | str starts-with "player.") and ($t | str ends-with ".games")
}

def main [src: string, dst: string] {
  if not ($src | path exists) { error make {msg: $"src does not exist: ($src)"} }
  if ($dst | path exists)     { error make {msg: $"dst already exists: ($dst)"} }

  let frames = (
    open --raw $src
    | lines
    | where {|l| ($l | str trim) != "" }
    | each {|l| $l | from json }
  )
  let total = ($frames | length)

  # game id (= player.<uuid>.games frame id) -> owner uuid, used to
  # backfill user_id into pre-stamp move frames.
  let owners = (
    $frames
    | where {|fr| is-games $fr.topic }
    | reduce --fold {} {|fr acc|
        $acc | upsert $fr.id ($fr.topic | str replace "player." "" | str replace ".games" "")
      }
  )

  # Keep raw game data only; rewrite old-shape move topics to new shape
  # and backfill any missing user_id from the game's owner.
  let kept = (
    $frames
    | where {|fr| (is-move $fr.topic) or (is-games $fr.topic) }
    | each {|fr|
        if (is-games $fr.topic) { return $fr }
        let topic = ($fr.topic | str replace --regex '^game\.([a-z0-9]+)\.move$' 'game.move.$1')
        let gid = ($topic | str replace "game.move." "")
        let uid = ($fr.meta | get user_id? | default "")
        let meta = if ($uid | is-empty) {
          $fr.meta | default {} | upsert user_id ($owners | get --optional $gid | default "")
        } else {
          $fr.meta
        }
        $fr | update topic $topic | update meta $meta
      }
  )

  # Trailing newline so line-at-a-time readers (e.g. shell `while read`)
  # don't drop the last frame; nushell `lines` is fine either way.
  ($kept | each {|fr| $fr | to json --raw } | str join "\n") + "\n" | save --raw $dst

  let games = ($kept | where {|fr| is-games $fr.topic } | length)
  let moves = ($kept | where {|fr| is-move $fr.topic } | length)
  # backfilled: source moves that arrived with no user_id (counted before
  # enrichment, so the topic is still in either shape).
  let backfilled = (
    $frames
    | where {|fr| is-move $fr.topic }
    | where {|fr| ($fr.meta | get user_id? | default "") == "" }
    | length
  )
  let orphans = (
    $kept
    | where {|fr| is-move $fr.topic }
    | where {|fr| ($owners | get --optional ($fr.topic | str replace "game.move." "")) == null }
    | length
  )
  let kn = ($kept | length)
  print $"read ($total) frames  ->  kept ($kn)  dropped (($total - $kn))"
  print $"  player.<uuid>.games          : ($games)"
  print $"  game.move.<id>               : ($moves)"
  print $"  moves backfilled with user_id: ($backfilled)"
  print $"  orphan moves \(no owning game\): ($orphans)"
}