Synsemadocsv0.6.xENES

Biblioteca estándar

Archivos e I/O

Todo acceso a archivos está gateado por la capacidad file (file.read / file.write para mínimo privilegio). Las escrituras son atómicas (archivo temporal + rename).

Un scope es un glob por segmento (engine v0.6.45+): file.read("./data/") son los archivos que están directamente en ./data; file.read("./data/*") es ./data misma y todo lo que hay abajo, a cualquier profundidad. Las reglas completas — .., links, el disco entero — están en Capacidades.

io.syn
-- Doc example: files & I/O, and the file formats (CSV, Parquet, JSON Lines). Needs the file capability (deny-by-default).
intent: "doc example: files and I/O"
require file("_doctest_io.txt")
require file("_doctest_io.parquet")
require file("_doctest_io.jsonl")
require file.write("_doctest_io_gone.txt")
require file.read(".")           -- cwd() needs exactly this: the same grant list_dir(".") needs
require file("./_doctest_tree/**")   -- `**`: the folder, its files AND every subfolder (v0.6.45+)
require file.read("./_doctest_flat/*")   -- `*`: only the files directly inside, one level

write_file("_doctest_io.txt", "hi from the io demo")
print("read back → " + read_file("_doctest_io.txt"))

test "delete_file needs file.write on the path and is honest about what happened (engine v0.6.20+)"
    write_file("_doctest_io_gone.txt", "bye")
    assert_eq(delete_file("_doctest_io_gone.txt"), true)
    assert_error(() => delete_file("_doctest_io_gone.txt"))     -- already gone → error, not false

test "cwd() is the real working directory, normalized to forward slashes (engine v0.6.20+)"
    assert_eq(type_of(cwd()), "text")
    assert(length(cwd()) > 0)
    assert_eq(contains(cwd(), "\\"), false)

test "write then read round-trips (atomic write)"
    write_file("_doctest_io.txt", "hello\nworld")
    assert_eq(read_file("_doctest_io.txt"), "hello\nworld")
    assert(file_exists("_doctest_io.txt"))
    assert_eq(keys(file_info("_doctest_io.txt")), ["exists", "is_dir", "size", "modified", "is_link"])
    assert_eq(file_info("_doctest_io.txt").size, 11)

test "path scopes are globs by segment: `*` stays in one folder, `**` crosses them (v0.6.45+)"
    when file_exists("_doctest_tree")
        delete_dir("_doctest_tree", {"recursive": true})              -- a fresh tree for this test
    write_file("_doctest_tree/top.txt", "top")
    write_file("_doctest_tree/sub/deep.txt", "deep")
    assert_eq(read_file("_doctest_tree/sub/deep.txt"), "deep")       -- `**` reaches every level
    assert_eq(length(list_dir("_doctest_tree")), 2)                  -- ... and the folder itself
    assert_error(() => read_file("_doctest_tree/../_doctest_x.txt"))  -- no wildcard matches `..`
    assert_error(() => read_file("_doctest_flat/sub/x.txt"))         -- `*` is one level: denied
    assert_error(() => list_dir("_doctest_flat"))                    -- `./x/*` names the files, not the folder

test "grep on a folder reads the tree, so it needs `<folder>/**`; edit_file reads before it writes"
    write_file("_doctest_tree/sub/deep.txt", "needle here")
    let g be grep("_doctest_tree", "needle")
    assert_eq(apply(g.matches, (m) => m.file), ["_doctest_tree/sub/deep.txt"])
    assert_eq(edit_file("_doctest_tree/sub/deep.txt", "needle", "pin"), {"replaced": 1})   -- file.read + file.write
    assert_eq(read_file("_doctest_tree/sub/deep.txt"), "pin here")
    delete_dir("_doctest_tree", {"recursive": true})

task why_not(path)
    try
        read_file(path)
    recover e
        give e
    give "read"

test "read_file says why it cannot read (v0.6.45+): File not found is only for what does not exist"
    write_file("_doctest_tree/bin.dat", bytes([255, 254, 0]))
    write_file("_doctest_tree/sub/deep.txt", "deep")
    assert(starts_with(why_not("_doctest_tree/nope.txt"), "File not found"))
    assert(contains(why_not("_doctest_tree/sub"), "it is a directory (use list_dir)"))
    assert(contains(why_not("_doctest_tree/bin.dat"), "not valid UTF-8 text (use read_file_bytes)"))
    assert_eq(read_file_bytes("_doctest_tree/bin.dat"), bytes([255, 254, 0]))
    delete_dir("_doctest_tree", {"recursive": true})

test "gzip / gunzip: bytes in, bytes out; deterministic; a bomb is stopped (v0.6.45+)"
    let z be gzip("hello hello hello")                                -- text or bytes in, bytes out
    assert_eq(decode(gunzip(z), "utf8"), "hello hello hello")
    assert_eq(gzip("same"), gzip("same"))                             -- no name or time in the header
    assert_eq(decode(gunzip(z + gzip(" world")), "utf8"), "hello hello hello world")   -- concatenated members
    assert_error(() => gunzip(gzip("aaaaaaaaaaaaaaaaaaaa"), {"max_bytes": 10}))

test "csv_parse: an empty field is nothing, a quoted \"\" is the empty text, untyped text stays lossless"
    assert_eq(csv_parse("a,b\n007,\n"), [{"a": "007", "b": nothing}])
    assert_eq(csv_parse("a,b\n\"\",\n"), [{"a": "", "b": nothing}])
    assert_eq(csv_parse("x\n1\n\n2\n"), [{"x": "1"}, {"x": "2"}])            -- a blank line is skipped
    assert_eq(csv_parse("a;b\n1;2\n", {"delimiter": ";"}), [{"a": "1", "b": "2"}])
    assert_eq(csv_parse("1,2\n3,4\n", {"headers": false}), [["1", "2"], ["3", "4"]])
    assert_eq(csv_parse("a,b\n007,2\n", {"numbers": true}), [{"a": 7, "b": 2}])    -- the old guess: ids lose zeros

test "csv_parse types: int, float, decimal (exponent too), text, bool, date, datetime"
    let rows be csv_parse("a,b,c,d,e,f,g\n007,1.5,1.5e-3,x,yes,2026-01-02,2026-01-03T10:00:00\n", {"types": {"a": "int", "b": "float", "c": "decimal", "d": "text", "e": "bool", "f": "date", "g": "datetime"}})
    assert_eq(rows[0].a, 7)
    assert_eq(rows[0].b, 1.5)
    assert_eq(rows[0].c, 0.0015d)
    assert_eq(rows[0].d, "x")
    assert_eq(rows[0].e, true)
    assert_eq(rows[0].f, date(2026, 1, 2))
    assert_eq(rows[0].g, datetime("2026-01-03T10:00:00Z"))                  -- no zone → UTC
    assert_eq(csv_parse("a,b\n,1\n", {"types": {"a": "int"}}), [{"a": nothing, "b": "1"}])   -- empty is nothing, typed or not
    let msg be ""
    try
        csv_parse("a\nx\n", {"types": {"a": "int"}})
    recover e
        set msg to e
    assert(contains(msg, "line 2") and contains(msg, "column \"a\""))
    assert_error(() => csv_parse("a\n1e400\n", {"types": {"a": "float"}}))      -- never infinity
    assert(is_nan(csv_parse("a\nnan\n", {"types": {"a": "float"}})[0].a))
    assert_error(() => csv_parse("a\n1\n", {"types": {"zz": "int"}}))          -- not in the header
    assert_error(() => csv_parse("a,b\n1\n"))                                   -- uneven fields
    assert_error(() => csv_parse("a\n1\n", {"delimiter": "\""}))

test "missing marks: {missing: [...]} reads them as nothing; csv_encode writes them back"
    assert_eq(csv_parse("a,b\nNA,1\n\"NA\",2\n", {"missing": ["NA"]}), [{"a": nothing, "b": "1"}, {"a": "NA", "b": "2"}])
    let rows be [{"a": nothing, "b": "NA"}, {"a": "x", "b": nothing}]
    assert_eq(csv_encode(rows, {"missing": "NA", "eol": "\n"}), "a,b\nNA,\"NA\"\nx,NA\n")
    assert_eq(csv_parse(csv_encode(rows, {"missing": "NA"}), {"missing": ["NA"]}), rows)
    assert_error(() => csv_encode(rows, {"missing": ""}))

test "csv_encode: nothing and empty text apart, dates as ISO, headers/eol, escape_formulas"
    assert_eq(csv_encode([{"a": "", "b": nothing}], {"eol": "\n"}), "a,b\n\"\",\n")
    let tricky be [{"a": "", "b": nothing, "c": "x,y"}]
    assert_eq(csv_parse(csv_encode(tricky)), tricky)
    assert_eq(csv_encode([{"d": date(2026, 1, 31), "u": duration(hours = 1)}], {"eol": "\n"}), "d,u\n2026-01-31,PT1H\n")
    assert_eq(csv_encode([{"a": "1", "b": "2"}], {"headers": ["b"], "eol": "\n"}), "b\n2\n")
    assert_eq(csv_encode([["x", "y"]], {"delimiter": ";", "eol": "\n"}), "x;y\n")
    assert_eq(csv_encode([{"f": "=SUM(A1)", "n": -5}], {"escape_formulas": true, "eol": "\n"}), "f,n\n'=SUM(A1),-5\n")
    assert_error(() => csv_encode([{"a": [1, 2]}]))                          -- nested: json_encode that field
    assert_eq(csv_encode([{"b": bytes("Hi")}], {"eol": "\n"}), "b\nSGk=\n")
    assert_eq(csv_encode([{"a": nothing}], {"eol": "\n"}), "a\n\"\"\n")         -- one column: nothing is written "" (warns once)
    assert_eq(csv_parse(csv_encode([{"a": nothing}]), {"types": {"a": "int"}}), [{"a": nothing}])
    assert_error(() => csv_parse("a,a\n1,2\n"))                               -- duplicate headers
    assert_error(() => csv_parse("a\n\"open\n"))                              -- unclosed quote

test "parquet: typed columns round-trip through a file"
    let rows be [
        {"id": 1, "x": 1.5, "p": 1.50d, "s": "a", "ok": true, "b": bytes("hi"), "d": date(2026, 1, 3), "t": datetime("2026-01-03T10:00:00", "Europe/Madrid"), "u": duration(minutes = 90), "n": nothing},
        {"id": 2, "x": 2.0, "p": 10.25d, "s": "b", "ok": false, "b": bytes("yo"), "d": date(2026, 1, 4), "t": datetime("2026-01-04T10:00:00", "Europe/Madrid"), "u": duration(seconds = 1), "n": nothing}]
    write_file("_doctest_io.parquet", parquet_write(rows, {"compression": "zstd"}))
    let back be parquet_read(read_file_bytes("_doctest_io.parquet"))
    assert_eq(back, rows)
    assert_eq(text(back[0].t), "2026-01-03T10:00:00+01:00[Europe/Madrid]")   -- the zone comes back
    assert_eq(type_of(back[0].u), "duration")
    each c in ["snappy", "gzip", "lz4", "none"]
        assert_eq(parquet_read(parquet_write(rows, {"compression": c})), rows)
    let f be decode(parquet_write(rows), "utf8_lossy")
    assert(contains(f, "ARROW:schema"))                                      -- pandas / pyarrow / polars read the types
    assert_eq(text(parquet_read(parquet_write([{"t": datetime("2026-01-03T14:30:00+05:30")}]))[0].t), "2026-01-03T14:30:00+05:30")
    assert_eq(parquet_read(parquet_write([{"t": datetime("2026-01-03T10:00:00.123456789Z")}]))[0].t, datetime("2026-01-03T10:00:00.123456789Z"))
    assert_eq(parquet_read(parquet_write([{"a": 1}, {"a": 2.5}])), [{"a": 1.0}, {"a": 2.5}])      -- int + float → DOUBLE
    assert_eq(parquet_read(parquet_write([{"a": 1}, {"b": 2}])), [{"a": 1, "b": nothing}, {"a": nothing, "b": 2}])

test "parquet: errors instead of silent loss"
    assert_error(() => parquet_write([{"a": 1}, {"a": "x"}]))                -- one type per column
    assert_error(() => parquet_write([{"a": [1, 2]}]))                       -- nested: json_encode it first
    assert_error(() => parquet_write([{"a": 1.5}, {"a": 9007199254740993}])) -- a DOUBLE would round it
    assert_error(() => parquet_write([{"a": 2 ** 70}]))                      -- beyond 64 bits
    let msg be ""
    try
        parquet_write([{"t": datetime("2026-01-03T10:00:00.123456789Z")}, {"t": datetime("2300-01-01T00:00:00Z")}])
    recover e
        set msg to e
    assert(contains(msg, "column \"t\""))                                    -- nanoseconds + a far date: one unit cannot hold both
    assert_error(() => parquet_write([{"a": 1}], {"compression": "brotli"}))
    assert_error(() => parquet_read(bytes("not parquet")))
    let small be parquet_write([{"a": 1, "b": 2}, {"a": 3, "b": 4}])
    assert_error(() => parquet_read(small, {"max_cells": 3}))                -- 4 cells > 3
    assert_eq(length(parquet_read(small, {"max_cells": 4, "max_bytes": 1000000})), 2)
    assert_error(() => parquet_read(small, {"bogus": 1}))

test "parquet: read a part of a file, or only its shape (v0.6.45+)"
    let p be parquet_write([{"a": 1, "b": "x"}, {"a": 2, "b": "y"}, {"a": 3, "b": "z"}])
    let info be parquet_info(p)                                      -- no data is read
    assert_eq(info.rows, 3)
    assert_eq(info.columns, [{"name": "a", "type": "int64"}, {"name": "b", "type": "string"}])
    assert_eq(length(info.row_groups), 1)
    assert_eq(parquet_read(p, {"columns": ["a"], "limit": 2}), [{"a": 1}, {"a": 2}])
    assert_eq(length(parquet_read(p, {"row_groups": [0]})), 3)
    assert_error(() => parquet_read(p, {"columns": ["zz"]}))          -- "the file has no column \"zz\" (columns: a, b)"

test "parquet_scan reads the part it is asked for straight from the disk (v0.6.46+)"
    write_file("_doctest_io.parquet", parquet_write([{"a": 1, "b": "x"}, {"a": 2, "b": "y"}, {"a": 3, "b": "z"}]))
    assert_eq(parquet_scan("_doctest_io.parquet", {"columns": ["a"], "limit": 2}), [{"a": 1}, {"a": 2}])
    assert_eq(keys(parquet_scan("_doctest_io.parquet", {"columns": ["b", "a"], "limit": 1})[0]), ["a", "b"])   -- the file's column order
    assert_eq(parquet_scan("_doctest_io.parquet"), parquet_read(read_file_bytes("_doctest_io.parquet")))
    assert_eq(parquet_scan("_doctest_io.parquet", {"row_groups": []}), [])
    let last be lineage()[-1]                                        -- an input with the path, hashed as canonical_json
    assert_eq(last.source, "parquet_scan")
    assert_eq(last.what, "_doctest_io.parquet")
    assert_eq(last.encoding, "jcs")
    assert_eq(last.sha256, decode(sha256(canonical_json([])), "hex"))
    assert_error(() => parquet_scan(read_file_bytes("_doctest_io.parquet")))   -- bytes: that is parquet_read
    assert_error(() => parquet_scan("_doctest_io.parquet", {"row_groups": [1]}))
    assert_error(() => parquet_scan("_doctest_io.parquet", {"limit": 0}))
    assert_error(() => parquet_scan("_doctest_io.parquet", {"bogus": 1}))
    assert_error(() => parquet_scan("_doctest_not_granted.parquet"))      -- needs file.read on the path

test "JSON Lines files"
    write_file("_doctest_io.jsonl", jsonl_encode([{"ev": "a"}, {"ev": "b"}]))
    assert_eq(collect(jsonl_decode(read_file("_doctest_io.jsonl")), "ev"), ["a", "b"])

Leer§

read_file(path)                 -- whole file as text
read_file(path, 1, 100)         -- a line range: lines 1–100 (1-based, EOLs preserved)
read_file(path, 500)            -- from line 500 to EOF
read_file_bytes(path)           -- byte-exact, for binary

read_file dice por qué no puede leer un archivo (engine v0.6.45+). File not found: <path> es solo para una ruta que no existe; todo lo demás nombra su causa:

El error diceQué significa
… it is a directory (use list_dir)la ruta es una carpeta
… it is not valid UTF-8 text (use read_file_bytes)un archivo binario, o texto en otra codificación
… in use by another process … retry laterWindows: otro proceso tiene el archivo abierto sin compartirlo
un link roto, un permisoeso, con su nombre

read_file_bytes y edit_file dan las mismas respuestas.

Escribir y editar§

write_file(path, text)          -- atomic overwrite
append_file(path, text)         -- append
edit_file(path, old, new)       -- exact-string replace (old must be unique; replace_all? for all)

edit_file necesita file.read además de file.write sobre la ruta (engine v0.6.45+): lee el archivo para encontrar el texto, y sus respuestas (no encontrado, ambiguo, cuántos reemplazó) dicen qué hay adentro. require file("./notes/**") da las dos.

file_exists(path)               -- bool
file_info(path)                 -- {exists, is_dir, size, modified, is_link}
list_dir(path)                  -- list of {name, is_dir, size, is_link}
grep(target, pattern, opts?)    -- {matches: [{file, line, col, text}], truncated} — streams per line

Borrar (engine v0.6.20+)§

delete_file(path)                        -- true; a missing file (or a directory) is an error, never a silent false
delete_dir(path)                         -- empty directories only — "is not empty (pass {\"recursive\": true} …)"
delete_dir(path, {"recursive": true})    -- the whole tree

Borrar es escribir: delete_file necesita file.write sobre esa ruta; delete_dir la necesita sobre el directorio y, en recursivo, sobre cada ruta de adentro — la misma grant que te dejaría borrarlas una por una. Con file.write("./tmp") sola, un borrado recursivo de ./tmp se rechaza en el primer hijo (file_write("tmp/k")) y no se borra nada; file.write("./tmp/**") cubre la carpeta y todo lo que tiene abajo. Se chequean todas las rutas antes de borrar la primera. Una ruta dentro del bundle de synsema build es de solo lectura, así que borrarla se rechaza.

Dónde estoy — cwd(), ~ (engine v0.6.20+)§

cwd() devuelve el directorio de trabajo real, normalizado con barras (C:/Users/me/proj), y necesita file.read(".") — la misma grant que necesita list_dir("."); file.read("./") y file.read("") también la cubren. file.read("./") y file.read("") no (engine v0.6.45+): nombran los archivos de la carpeta, no la carpeta. La ruta absoluta es información del host (nombre de usuario, disposición del disco), así que bajo --sandbox no es gratis. En un binario de synsema build es el directorio desde donde se lanzó el binario, nunca el bundle.

~ y ~/… expanden al home (HOME, si no USERPROFILE) en las rutas y en los scopes: require file.read("~/.config/app/*") y después read_file("~/.config/app/x.toml"). ~usuario/ no está soportado (error claro).

Los assets del programa vs. los archivos del usuario — bundle: / disk:§

En un binario hecho con synsema build, una ruta que está en el bundle es parte del programa: read_file, read_file_bytes, file_exists, file_info y grep la leen del bundle, sin capacidad file y con una línea de audit que lo dice (bundled asset (part of the program)); un archivo con el mismo nombre relativo en el directorio de trabajo jamás lo tapa. Una ruta que no está en el bundle es del usuario y va al disco con file.read, como siempre. Dos prefijos lo hacen explícito: read_file("bundle:data.txt") fuerza el bundle (error si no está), read_file("disk:data.txt") fuerza el disco (necesita file.read). list_dir(path) lista solo el disco; list_dir("bundle:") y list_dir("bundle:sub/") listan el bundle.

Archivos comprimidos — zip y tar (engine v0.6.20+)§

let z be zip_create([{"path": "a.txt", "bytes": bytes("hi")},
                     {"path": "dir/hello.txt", "from": "./data/hello.txt"}])   -- bytes
zip_extract(z, "./out")                          -- ["a.txt", "dir/hello.txt"] — what was written
let t be tar_create(entries, {"gzip": true})     -- .tar.gz bytes
tar_extract(t, "./out2")                         -- gzip detected by its magic bytes

gzip — gzip / gunzip (engine v0.6.45+)§

Para archivos .json.gz / .csv.gz sueltos (un .tar.gz es tar_extract):

require file.read("./in/*")
require file.write("./out/*")

let raw be gunzip(read_file_bytes("./in/events.json.gz"))      -- bytes
let rows be json_decode(decode(raw, "utf8"))
write_file("./out/rows.json.gz", gzip(json_encode(rows)))     -- text or bytes in, bytes out
let big be gunzip(read_file_bytes("./in/dump.csv.gz"), {"max_bytes": 2147483648})   -- raise the cap on purpose

Formatos de archivo — CSV, Parquet, JSON Lines§

Los builtins de formato son puros: entra texto o bytes, salen valores. El disco es read_file / read_file_bytes / write_file, con su propia capacidad file. Todos producen o toman una tabla — una lista de maps, la misma forma que devuelve sql() — que es sobre lo que trabaja Análisis de datos.

require file.read("./data/*")
require file.write("./out/*")
let sales be csv_parse(read_file("./data/sales.csv"), {"types": {"day": "date", "amount": "decimal"}})
let events be jsonl_decode(read_file("./data/events.jsonl"))
let orders be parquet_read(read_file_bytes("./data/orders.parquet"))
write_file("./out/sales.parquet", parquet_write(sales, {"compression": "zstd"}))

CSV — csv_parse / csv_encode§

csv_parse(text, opts?) → una lista de maps (primera fila = cabeceras). RFC 4180 completo: campos entre comillas, delimitadores, comillas y saltos de línea embebidos, escapes "", CRLF/LF, un BOM UTF-8.

OpciónSignificado
"types"{"col": tipo} por columna — la forma recomendada. "int" (exacto: "007" → 7), "float" (lee nan/inf; 1e400 es un error, nunca infinito), "decimal" (exacto, cualquier cantidad de dígitos, con exponente: 1.5e-3 → 0.0015), "text", "bool" (true/false/1/0/yes/no, en cualquier caja), "date" (YYYY-MM-DD), "datetime" (ISO 8601; sin zona → UTC). Las columnas sin nombrar quedan texto; nombrar una columna que la cabecera no tiene es un error
"missing"una lista de textos que significan faltante, como ["NA", "NULL", "-"] (el null_values de polars, el na_values de pandas): esos textos sin comillas son nothing; un "NA" entre comillas queda texto
"headers"false → una lista de listas (todas las filas son datos)
"delimiter"un carácter, p. ej. ";" o "\t" (no la comilla ni un fin de línea)
"numbers"true → la adivinanza vieja: todo campo con pinta numérica se vuelve número (los ids y códigos postales pierden sus ceros). Preferí types

csv_encode(rows, opts?) → texto. Las filas son una lista de maps (cabeceras = las claves del primer map, en su orden) o una lista de listas. Enteros sin decimales; nothing → un campo vacío y el texto vacío → "", así que csv_parse(csv_encode(rows)) == rows; fechas, datetimes y durations → su texto ISO; bytes → base64; un secret → un error (motor v0.6.46+: csv_encode: row 1, column "k": secret(K) is never written as text — leave it out of the rows; antes salía como [redacted]); una lista o map anidado → un error que sugiere json_encode para ese campo.

OpciónSignificado
"headers"una lista de nombres de columna → el orden y el subconjunto de columnas
"delimiter"como en csv_parse
"eol""\r\n" (default, compatible con Excel) o "\n"
"missing"una marca que se escribe por cada nothing, sin comillas, como "NA" (el na_rep de pandas); un texto igual a la marca va entre comillas. Leelo de vuelta con {"missing": ["NA"]} para un ida y vuelta exacto. Una marca vacía es un error
"escape_formulas"true → una celda de texto que empieza con =, +, -, @, tab o CR lleva un ' adelante, así Excel y Sheets la muestran en vez de ejecutarla (la "CSV injection" de OWASP). Los números no se tocan. Apagado por defecto, porque cambia los datos — prendelo en todo CSV que una persona abra en una planilla con texto que vino de usuarios

Un caso borde: en un CSV de una sola columna, una fila cuyo único campo es nothing sería una línea en blanco, que la lectura saltea, así que se escribe "" (como hace el writer de Python) y vuelve como texto vacío — o nothing si leés esa columna con tipo; csv_encode avisa una vez por stderr. La opción "missing" vuelve exacto ese ida y vuelta también.

Parquet — parquet_read / parquet_write§

Archivos columnares como los que escriben polars, pandas, Spark y DuckDB. Entran bytes, salen bytes. Solo en builds nativos (no en el build wasm).

parquet_read(bytes, opts?) → filas tipadas: Int64 → entero exacto, Double/Float → float, Decimal → decimal, String → texto, Binary → bytes, Bool → bool, Date → date, Timestamp → datetime (UTC, o la zona de la columna — la que guardó Synsema, o la que escribió polars/pyarrow, offsets fijos como +05:30 incluidos; mili-, micro- y nanosegundos), Duration → duration, Time (hora del día) → una duration desde medianoche, listas y grupos → listas y maps anidados, null → nothing. Compresión zstd, snappy, gzip y lz4. Un archivo que no es Parquet, o que tiene dos columnas con el mismo nombre, es un error.

#### parquet_scan — una parte de un archivo, directo del disco (engine v0.6.46+)

parquet_read recibe los bytes del archivo, así que primero se lee el archivo entero a memoria. parquet_scan(path, opts?) lee el archivo en disco, por partes: el pie con los metadatos y después solo las páginas de las columnas y los row groups que se le piden. Un archivo de gigas con una columna pedida lee esa columna.

require file.read("./data/**")
let few be parquet_scan("./data/events.parquet", {"columns": ["id", "kind"], "limit": 1000})
let first_group be parquet_scan("./data/events.parquet", {"row_groups": [0]})
let everything be parquet_scan("./data/events.parquet")
ErrorArreglo
parquet_scan: expected a path, got bytes — for bytes already in memory, parquet_read(bytes)pasá la ruta, o usá parquet_read
parquet_scan: the file has no column "zz" (columns: id, name, v)un nombre de esa lista
parquet_scan: row group 1 does not exist (the file has 1: 0..1)un índice menor que la cantidad de parquet_info(…).row_groups
parquet_scan: unknown option "bogus" (valid: columns, row_groups, limit, max_cells, max_bytes)una de esas
parquet_scan: option "limit" must be a positive integer, got 0un entero desde 1
parquet_scan: opts must be a map, got textun map
parquet_scan: the file has 30 cells (rows × columns); the limit is 5 — raise it on purpose with {"max_cells": n}leé menos, o subilo para un archivo en el que confiás
parquet_scan: the file expands to more than 10 bytes uncompressed (max_bytes) — raise it on purpose with {"max_bytes": n}lo mismo
parquet_scan: a page declares 2047 MiB uncompressed (the limit is 256 MiB per page; writers use about 1 MiB) — the file is malformed or hostile, refusing to allocate itel archivo está roto o es hostil
parquet_scan: not a Parquet file: …no es Parquet
parquet_scan: the file has two columns named "a" — a row is a map and would lose one; rename it where the file is writtenreescribí el archivo con nombres distintos
File not found: missing.parquetla ruta
`Capability not granted: file_read("other.parquet") — … add require file.read("other.parquet") …`ese require

parquet_write(rows, opts?) → bytes. Un esquema por columna, inferido, toda columna admite nulos: int → INT64, float → DOUBLE (una columna que mezcla ints y floats → DOUBLE), decimal → DECIMAL(38, la escala más grande de la columna), texto → STRING, bool → BOOLEAN, bytes → BINARY, date → DATE, datetime → TIMESTAMP en µs (ns cuando un valor tiene nanosegundos) con su zona, duration → INT64 en µs (o ns) con su unidad. Una clave que falta en una fila es nothing ahí. opts.compression = "snappy" (default), "zstd", "gzip", "lz4" o "none".

JSON Lines — jsonl_encode / jsonl_decode§

Un valor JSON por línea: jsonl_decode(read_file("events.jsonl")), write_file("out.jsonl", jsonl_encode(rows)). Las líneas en blanco se saltean y una línea mala es un error que nombra su número. El contrato completo (enteros grandes, NaN, forma con fallback) está en JSON, XML y TOML.

Los scopes de ruta son fieles — un escape con .. fuera del scope declarado se deniega, y un link (symlink o junction) debajo de la grant que lleva afuera se rechaza (engine v0.6.45+). Ver Capacidades.

Sin ventana entre el chequeo y la apertura (motor v0.6.46+, Linux y Windows). v0.6.45 chequeaba una ruta en disco y después la abría por nombre, así que una carpeta cambiada por un link en el medio se seguía afuera del permiso. Ahora el archivo se abre sin seguir ningún link (openat2 con RESOLVE_NO_SYMLINKS en Linux, NtCreateFile con OBJ_DONT_REPARSE en Windows): si abre, es el archivo que juzgó el chequeo. Con un link en el camino, se abre siguiéndolo pero sin acceso a sus datos, el mismo chequeo corre de nuevo sobre dónde está de verdad, y recién ahí se reabre el mismo archivo con el acceso pedido. Un link que queda dentro del permiso sigue funcionando. Para reemplazar un archivo (write_file, edit_file, zip_extract/tar_extract) la carpeta se abre igual, y el archivo temporal, el rename y la limpieza van todos relativos a ella, así el nombre nunca se vuelve a resolver.