From 18a01fdf436a71b2f4b6bd73672f2008ae05f880 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 20 May 2026 19:42:24 -0500 Subject: [PATCH 1/4] feat: add `filter` to `db.query` Signed-off-by: Trezy --- src/lua/db_api.rs | 241 ++++++++++++++++++++++++++++------------------ 1 file changed, 149 insertions(+), 92 deletions(-) diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index 1937267..6ab7a2a 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -7,11 +7,114 @@ use std::sync::Arc; use crate::AppState; use crate::db::{DatabaseBackend, adapt_sql, decode_cursor, encode_cursor}; +const MAX_FILTER_DEPTH: u8 = 5; +const ALLOWED_OPS: &[&str] = &["=", "!=", "<", ">", "<=", ">=", "LIKE", "NOT LIKE"]; + +enum FilterNode { + Condition { + field: String, + op: String, + value: String, + }, + Group { + combine: String, + children: Vec, + }, +} + +fn parse_filter_node(table: &mlua::Table, depth: u8) -> LuaResult { + if depth > MAX_FILTER_DEPTH { + return Err(mlua::Error::runtime( + "filter nesting too deep (max 5 levels)", + )); + } + + if let Ok(field) = table.get::("field") { + let valid = field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !valid || field.is_empty() { + return Err(mlua::Error::runtime(format!( + "invalid filter field '{field}': only alphanumeric characters and underscores are allowed", + ))); + } + + let op: String = table + .get::("op") + .unwrap_or_else(|_| "=".to_string()); + let op_upper = op.to_uppercase(); + if !ALLOWED_OPS.contains(&op_upper.as_str()) { + return Err(mlua::Error::runtime(format!( + "invalid filter op '{op}': must be one of {ALLOWED_OPS:?}", + ))); + } + + let val: mlua::Value = table.get("value")?; + let value = match val { + mlua::Value::String(s) => s.to_str()?.to_string(), + mlua::Value::Integer(n) => n.to_string(), + mlua::Value::Number(n) => n.to_string(), + mlua::Value::Boolean(b) => (if b { "true" } else { "false" }).to_string(), + other => { + return Err(mlua::Error::runtime(format!( + "unsupported filter value type for '{field}': {}", + other.type_name() + ))); + } + }; + + return Ok(FilterNode::Condition { + field, + op: op_upper, + value, + }); + } + + let combine: String = table + .get::("combine") + .unwrap_or_else(|_| "AND".to_string()) + .to_uppercase(); + if combine != "AND" && combine != "OR" { + return Err(mlua::Error::runtime(format!( + "invalid filter combine '{combine}': must be 'AND' or 'OR'", + ))); + } + + let mut children = Vec::new(); + for child in table.sequence_values::() { + children.push(parse_filter_node(&child?, depth + 1)?); + } + + if children.is_empty() { + return Err(mlua::Error::runtime("filter group has no conditions")); + } + + Ok(FilterNode::Group { combine, children }) +} + +fn build_filter_sql(node: &FilterNode, binds: &mut Vec) -> String { + match node { + FilterNode::Condition { field, op, value } => { + binds.push(value.clone()); + format!("json_extract(record, '$.value.{field}') {op} ?") + } + FilterNode::Group { combine, children } => { + let parts: Vec = children + .iter() + .map(|c| build_filter_sql(c, binds)) + .collect(); + if parts.len() == 1 { + parts.into_iter().next().unwrap() + } else { + format!("({})", parts.join(&format!(" {combine} "))) + } + } + } +} + /// Register the `db` table with database query functions. pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let db_table = lua.create_table()?; - // db.query({ collection, did?, limit?, offset?, cursor?, sort?, sortDirection? }) -> { records, cursor? } + // db.query({ collection, did?, limit?, offset?, cursor?, sort?, sortDirection?, filter? }) -> { records, cursor? } let state_query = state.clone(); let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { let state = state_query.clone(); @@ -45,6 +148,16 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { } }; + let filter_table: Option = opts.get("filter").ok(); + let mut filter_binds: Vec = Vec::new(); + let filter_clause = if let Some(ref tbl) = filter_table { + let node = parse_filter_node(tbl, 0)?; + let sql = build_filter_sql(&node, &mut filter_binds); + format!(" AND {sql}") + } else { + String::new() + }; + let result_table = lua.create_table()?; if let Some(ref sort_field) = sort { @@ -68,32 +181,20 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { } }; - let rows: Vec<(String, String, String)> = if let Some(ref did) = did { - let sql = adapt_sql( - &format!("SELECT uri, did, record FROM records WHERE collection = ? AND did = ? ORDER BY {order_expr} LIMIT ? OFFSET ?"), - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(did) + let did_clause = if did.is_some() { " AND did = ?" } else { "" }; + let sql = adapt_sql( + &format!("SELECT uri, did, record FROM records WHERE collection = ?{did_clause}{filter_clause} ORDER BY {order_expr} LIMIT ? OFFSET ?"), + backend, + ); + let mut q = sqlx::query_as(&sql).bind(&collection); + if let Some(ref did) = did { q = q.bind(did); } + for val in &filter_binds { q = q.bind(val); } + let rows: Vec<(String, String, String)> = q .bind(limit) .bind(offset) .fetch_all(&state.db) .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } else { - let sql = adapt_sql( - &format!("SELECT uri, did, record FROM records WHERE collection = ? ORDER BY {order_expr} LIMIT ? OFFSET ?"), - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(limit) - .bind(offset) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - }; + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; let has_next = rows.len() as i64 == limit; @@ -126,76 +227,32 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { type RowType = (String, String, String, String); - let rows_raw: Vec = match (&did, &cursor_parts) { - (Some(did), Some((cursor_ts, cursor_uri))) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? AND did = ? AND (created_at < ? OR (created_at = ? AND uri < ?)) \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(did) - .bind(cursor_ts) - .bind(cursor_ts) - .bind(cursor_uri) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } - (Some(did), None) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? AND did = ? \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(did) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } - (None, Some((cursor_ts, cursor_uri))) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? AND (created_at < ? OR (created_at = ? AND uri < ?)) \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(cursor_ts) - .bind(cursor_ts) - .bind(cursor_uri) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } - (None, None) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } + let did_clause = if did.is_some() { " AND did = ?" } else { "" }; + let cursor_clause = if cursor_parts.is_some() { + " AND (created_at < ? OR (created_at = ? AND uri < ?))" + } else { + "" }; + let sql = adapt_sql( + &format!( + "SELECT uri, did, record, created_at FROM records \ + WHERE collection = ?{did_clause}{cursor_clause}{filter_clause} \ + ORDER BY created_at DESC, uri DESC \ + LIMIT ?" + ), + backend, + ); + let mut q = sqlx::query_as::<_, RowType>(&sql).bind(&collection); + if let Some(ref did) = did { q = q.bind(did); } + if let Some((cursor_ts, cursor_uri)) = &cursor_parts { + q = q.bind(cursor_ts).bind(cursor_ts).bind(cursor_uri); + } + for val in &filter_binds { q = q.bind(val); } + let rows_raw: Vec = q + .bind(limit) + .fetch_all(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; let has_next = rows_raw.len() as i64 == limit; -- 2.51.2 From 950594bb5d12eb7fefe3cc21a76ade00c242f675 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 20 May 2026 20:32:14 -0500 Subject: [PATCH 2/4] feat: add support for nexted syntax in field selectors Signed-off-by: Trezy --- src/db.rs | 53 +++++++++++++++++---- src/lua/db_api.rs | 119 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 144 insertions(+), 28 deletions(-) diff --git a/src/db.rs b/src/db.rs index 19f571a..114ced1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -100,22 +100,39 @@ pub fn adapt_sql(sql: &str, backend: DatabaseBackend) -> String { } /// Convert `json_extract(col, '$.seg1.seg2.leaf')` to Postgres `col::jsonb->'seg1'->'seg2'->>'leaf'`. +/// Handles array indices: `seg[0].leaf` becomes `->seg->0->>'leaf'`. fn adapt_json_extract_to_postgres(sql: &str) -> String { JSON_EXTRACT_RE .replace_all(sql, |caps: ®ex::Captures| { let col = &caps[1]; - let path = &caps[2]; // e.g. "defs.main.type" or "title" + let path = &caps[2]; + + let mut parts: Vec<(String, bool)> = Vec::new(); + for segment in path.split('.') { + let bracket_start = segment.find('[').unwrap_or(segment.len()); + let field_name = &segment[..bracket_start]; + if !field_name.is_empty() { + parts.push((field_name.to_string(), false)); + } + let mut rest = &segment[bracket_start..]; + while rest.starts_with('[') { + if let Some(close) = rest.find(']') { + parts.push((rest[1..close].to_string(), true)); + rest = &rest[close + 1..]; + } else { + break; + } + } + } - let segments: Vec<&str> = path.split('.').collect(); let mut chain = format!("{col}::jsonb"); - - for (i, seg) in segments.iter().enumerate() { - if i == segments.len() - 1 { - // Last segment uses ->> (text extraction) - chain.push_str(&format!("->>'{seg}'")); + let last = parts.len().saturating_sub(1); + for (i, (text, is_index)) in parts.iter().enumerate() { + let arrow = if i == last { "->>" } else { "->" }; + if *is_index { + chain.push_str(&format!("{arrow}{text}")); } else { - // Intermediate segments use -> (JSON traversal) - chain.push_str(&format!("->'{seg}'")); + chain.push_str(&format!("{arrow}'{text}'")); } } @@ -388,6 +405,24 @@ mod tests { ); } + #[test] + fn adapt_sql_postgres_converts_array_index_json_extract() { + let sql = "WHERE json_extract(record, '$.value.websites[0].url') = ?"; + assert_eq!( + adapt_sql(sql, DatabaseBackend::Postgres), + "WHERE record::jsonb->'value'->'websites'->0->>'url' = $1" + ); + } + + #[test] + fn adapt_sql_sqlite_keeps_array_index_json_extract() { + let sql = "WHERE json_extract(record, '$.value.tags[0]') = ?"; + assert_eq!( + adapt_sql(sql, DatabaseBackend::Sqlite), + "WHERE json_extract(record, '$.value.tags[0]') = ?" + ); + } + #[test] fn adapt_sql_multiple_json_expressions() { let sql = diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index 6ab7a2a..c51c10c 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -10,6 +10,38 @@ use crate::db::{DatabaseBackend, adapt_sql, decode_cursor, encode_cursor}; const MAX_FILTER_DEPTH: u8 = 5; const ALLOWED_OPS: &[&str] = &["=", "!=", "<", ">", "<=", ">=", "LIKE", "NOT LIKE"]; +fn is_valid_json_field_path(path: &str) -> bool { + if path.is_empty() { + return false; + } + for segment in path.split('.') { + if segment.is_empty() { + return false; + } + let bracket_start = segment.find('[').unwrap_or(segment.len()); + let ident = &segment[..bracket_start]; + if ident.is_empty() || !ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return false; + } + let mut rest = &segment[bracket_start..]; + while !rest.is_empty() { + if !rest.starts_with('[') { + return false; + } + let close = match rest.find(']') { + Some(i) => i, + None => return false, + }; + let idx = &rest[1..close]; + if idx.is_empty() || !idx.chars().all(|c| c.is_ascii_digit()) { + return false; + } + rest = &rest[close + 1..]; + } + } + true +} + enum FilterNode { Condition { field: String, @@ -30,10 +62,9 @@ fn parse_filter_node(table: &mlua::Table, depth: u8) -> LuaResult { } if let Ok(field) = table.get::("field") { - let valid = field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); - if !valid || field.is_empty() { + if !is_valid_json_field_path(&field) { return Err(mlua::Error::runtime(format!( - "invalid filter field '{field}': only alphanumeric characters and underscores are allowed", + "invalid filter field '{field}': use alphanumeric names with optional dot notation and array indices (e.g. 'name', 'author.handle', 'tags[0]')", ))); } @@ -127,14 +158,12 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let sort_direction: Option = opts.get("sortDirection").ok(); let cursor_str: Option = opts.get("cursor").ok(); - // Validate sort field name to prevent SQL injection - if let Some(ref field) = sort { - let valid = field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); - if !valid || field.is_empty() { - return Err(mlua::Error::runtime( - "invalid sort field: only alphanumeric characters and underscores are allowed", - )); - } + if let Some(ref field) = sort + && !is_valid_json_field_path(field) + { + return Err(mlua::Error::runtime( + "invalid sort field: use alphanumeric names with optional dot notation and array indices (e.g. 'name', 'author.handle', 'tags[0]')", + )); } let direction = match sort_direction.as_deref() { @@ -175,10 +204,7 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let order_expr = if top_level_columns.contains(&sort_field.as_str()) { format!("{sort_field} {direction}") } else { - match backend { - DatabaseBackend::Sqlite => format!("json_extract(record, '$.value.{sort_field}') {direction}"), - DatabaseBackend::Postgres => format!("record::jsonb->'value'->>'{sort_field}' {direction}"), - } + format!("json_extract(record, '$.value.{sort_field}') {direction}") }; let did_clause = if did.is_some() { " AND did = ?" } else { "" }; @@ -326,11 +352,9 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let query: String = opts.get("query")?; let limit: i64 = opts.get::("limit").unwrap_or(10).min(100); - // Validate field name to prevent SQL injection - let valid = field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); - if !valid || field.is_empty() { + if !is_valid_json_field_path(&field) { return Err(mlua::Error::runtime( - "invalid search field: only alphanumeric characters and underscores are allowed", + "invalid search field: use alphanumeric names with optional dot notation and array indices (e.g. 'name', 'author.handle', 'tags[0]')", )); } @@ -809,6 +833,63 @@ mod tests { } } + #[test] + fn valid_json_field_paths() { + assert!(super::is_valid_json_field_path("name")); + assert!(super::is_valid_json_field_path("author_name")); + assert!(super::is_valid_json_field_path("author.handle")); + assert!(super::is_valid_json_field_path("tags[0]")); + assert!(super::is_valid_json_field_path("data[0][1]")); + assert!(super::is_valid_json_field_path("author.websites[0].url")); + assert!(super::is_valid_json_field_path("a.b.c.d.e")); + } + + #[test] + fn invalid_json_field_paths() { + assert!(!super::is_valid_json_field_path("")); + assert!(!super::is_valid_json_field_path(".name")); + assert!(!super::is_valid_json_field_path("name.")); + assert!(!super::is_valid_json_field_path("name..foo")); + assert!(!super::is_valid_json_field_path("[0]")); + assert!(!super::is_valid_json_field_path("name[]")); + assert!(!super::is_valid_json_field_path("name[abc]")); + assert!(!super::is_valid_json_field_path("name; DROP TABLE")); + assert!(!super::is_valid_json_field_path("name'OR 1=1")); + assert!(!super::is_valid_json_field_path("na-me")); + } + + #[tokio::test] + async fn query_accepts_nested_sort_field() { + let state = test_state(); + let lua = setup(&state); + let result: Result = lua + .load(r#"return db.query({ collection = "test", sort = "author.handle" })"#) + .eval_async() + .await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + !err.contains("invalid sort field"), + "nested sort field should be accepted, got: {err}" + ); + } + + #[tokio::test] + async fn query_accepts_array_index_sort_field() { + let state = test_state(); + let lua = setup(&state); + let result: Result = lua + .load(r#"return db.query({ collection = "test", sort = "tags[0]" })"#) + .eval_async() + .await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + !err.contains("invalid sort field"), + "array index sort field should be accepted, got: {err}" + ); + } + #[tokio::test] async fn query_rejects_invalid_sort_field() { let state = test_state(); -- 2.51.2 From 698c099211eb053bf2d4f24cb60c0fed107b3a23 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 20 May 2026 20:33:10 -0500 Subject: [PATCH 3/4] fix: filter nesting was allowed to 6 levels instead of 5 Signed-off-by: Trezy --- src/lua/db_api.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index c51c10c..5899bbd 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -55,10 +55,10 @@ enum FilterNode { } fn parse_filter_node(table: &mlua::Table, depth: u8) -> LuaResult { - if depth > MAX_FILTER_DEPTH { - return Err(mlua::Error::runtime( - "filter nesting too deep (max 5 levels)", - )); + if depth >= MAX_FILTER_DEPTH { + return Err(mlua::Error::runtime(format!( + "filter nesting too deep (max {MAX_FILTER_DEPTH} levels)", + ))); } if let Ok(field) = table.get::("field") { -- 2.51.2 From a0edded64dd7cf5ef8fe69da7acc0f2040355443 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 20 May 2026 20:34:20 -0500 Subject: [PATCH 4/4] docs: add `db.query`s new `filter` prop to the docs Signed-off-by: Trezy --- .../docs/api-reference/lua/database-api.md | 94 +++++++++++++++++-- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/docs/content/docs/api-reference/lua/database-api.md b/packages/docs/content/docs/api-reference/lua/database-api.md index 64e4b4d..96c78b3 100644 --- a/packages/docs/content/docs/api-reference/lua/database-api.md +++ b/packages/docs/content/docs/api-reference/lua/database-api.md @@ -14,6 +14,7 @@ local result = db.query({ cursor = params.cursor, -- optional: opaque cursor from a previous response sort = "name", -- optional: field to sort by, default "indexed_at" sortDirection = "asc", -- optional: "asc" or "desc", default "desc" + filter = { field = "status", value = "active" }, -- optional: filter conditions }) -- result.records — array of record tables (each includes a "uri" field) @@ -22,7 +23,80 @@ local result = db.query({ The `cursor` is an opaque string returned in a previous response. Pass it through directly — don't parse or modify it. When no `sort` field is specified, `db.query` uses keyset pagination (based on `created_at` and `uri`), which is stable even when records are inserted between pages. When a custom `sort` field is specified, offset-based pagination is used instead. -The `sort` field can be a top-level column (`indexed_at`, `did`, `uri`) or any field inside the record's `value` object (e.g. `name`, `createdAt`). Field names must contain only alphanumeric characters and underscores. +The `sort` field can be a top-level column (`indexed_at`, `did`, `uri`) or any field inside the record (e.g. `name`, `createdAt`). Nested paths are supported with dot notation and array indices (e.g. `author.handle`, `scores[0]`). + +### Filtering + +The `filter` option lets you restrict results by record field values. Field names correspond to the fields defined in your lexicon schema (e.g. `streamer`, `status`, `viewers`). + +**Simple condition** — match a single field (operator defaults to `=`): + +```lua +db.query({ + collection = "xyz.statusphere.status", + filter = { field = "streamer", value = "did:plc:abc" }, +}) +``` + +**With operator** — specify a comparison operator: + +```lua +db.query({ + collection = "xyz.statusphere.status", + filter = { field = "viewers", op = ">", value = 100 }, +}) +``` + +Supported operators: `=`, `!=`, `<`, `>`, `<=`, `>=`, `LIKE`, `NOT LIKE`. + +**Combining conditions** — group multiple conditions with `AND` or `OR`: + +```lua +db.query({ + collection = "xyz.statusphere.status", + filter = { + combine = "AND", + { field = "streamer", value = "did:plc:abc" }, + { field = "viewers", op = ">", value = 50 }, + }, +}) +``` + +When `combine` is omitted it defaults to `"AND"`. + +**Nesting** — groups can contain other groups, up to 5 levels deep: + +```lua +db.query({ + collection = "xyz.statusphere.status", + filter = { + combine = "AND", + { field = "streamer", value = "did:plc:abc" }, + { + combine = "OR", + { field = "status", value = "live" }, + { field = "viewers", op = ">=", value = 100 }, + }, + }, +}) +``` + +This matches records where `streamer` is `did:plc:abc` **and** either `status` is `live` **or** `viewers` is at least 100. + +Field names support dot notation for nested objects and bracket syntax for array indices: + +```lua +-- Nested object field +filter = { field = "author.handle", value = "alice.bsky.social" } + +-- Array index +filter = { field = "tags[0]", value = "gaming" } + +-- Combined +filter = { field = "links[0].url", op = "LIKE", value = "%twitch.tv%" } +``` + +Each path segment must be alphanumeric or underscores. Values can be strings, numbers, or booleans. ## db.get @@ -101,15 +175,15 @@ Write SQL in **SQLite syntax** — HappyView translates it to Postgres at runtim ### Column type mapping -| SQLite type | Postgres type | Lua type | -| ---------------------- | ---------------------- | -------- | -| `TEXT` | `TEXT`, `VARCHAR` | string | -| `INTEGER` | `INT4`, `INT8` | integer | -| `REAL` | `FLOAT4`, `FLOAT8` | number | -| `INTEGER` (0/1) | `BOOL` | boolean | -| `TEXT` (JSON) | `JSON`, `JSONB` | table | -| `TEXT` (ISO 8601) | `TIMESTAMPTZ` | string (ISO 8601) | -| Other | Other | string (fallback) | +| SQLite type | Postgres type | Lua type | +| ----------------- | ------------------ | ----------------- | +| `TEXT` | `TEXT`, `VARCHAR` | string | +| `INTEGER` | `INT4`, `INT8` | integer | +| `REAL` | `FLOAT4`, `FLOAT8` | number | +| `INTEGER` (0/1) | `BOOL` | boolean | +| `TEXT` (JSON) | `JSON`, `JSONB` | table | +| `TEXT` (ISO 8601) | `TIMESTAMPTZ` | string (ISO 8601) | +| Other | Other | string (fallback) | ## db.backend -- 2.51.2