@tishlang/tish 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml CHANGED
@@ -12,6 +12,7 @@ members = [
12
12
  "crates/tish_opt",
13
13
  "crates/tish_eval",
14
14
  "crates/tish_runtime",
15
+ "crates/tish_pg",
15
16
  "crates/tish_compile",
16
17
  "crates/tish_ui",
17
18
  "crates/tish_jsx_web",
package/bin/tish CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang"
3
- version = "1.8.0"
3
+ version = "1.9.0"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -18,11 +18,10 @@ path = "src/main.rs"
18
18
  # “full” vs “empty” CLI.
19
19
  default = ["full"]
20
20
  # Alias: same dependency edges as `default` (kept for `--features full` in CI and docs).
21
- # Includes `pg` so `tish run` can resolve `import … from 'tish-pg'` (`cargo:tish_pg`) on the bytecode VM.
22
- # Requires `tish-pg` next to this workspace root (`…/tish/tish-pg`); CI runs `scripts/ci/ensure-tish-pg.sh`.
21
+ # Includes `pg` so `tish run` can resolve `import … from '@tishlang/pg'` (`cargo:tish_pg`) on the bytecode VM.
23
22
  full = ["http", "fs", "process", "regex", "ws", "timers", "pg"]
24
23
  # Opt-out: build without Postgres (`cargo build -p tishlang --no-default-features --features http,...`).
25
- pg = ["dep:tish_pg"]
24
+ pg = ["dep:tishlang_pg"]
26
25
  # Individual capability flags
27
26
  http = ["tishlang_eval/http", "tishlang_runtime/http", "tishlang_compile/http", "tishlang_vm/http"]
28
27
  timers = ["tishlang_eval/timers", "tishlang_compile/timers", "tishlang_vm/timers"]
@@ -49,7 +48,7 @@ tishlang_runtime = { path = "../tish_runtime", version = ">=0.1" }
49
48
  tishlang_core = { path = "../tish_core", version = ">=0.1" }
50
49
  tishlang_js_to_tish = { path = "../js_to_tish", version = ">=0.1" }
51
50
  clap = { version = "4.6.0", features = ["derive", "color"] }
52
- tish_pg = { path = "../../../tish-pg", package = "tish-pg", optional = true }
51
+ tishlang_pg = { path = "../tish_pg", version = ">=0.1", optional = true }
53
52
 
54
53
  [dev-dependencies]
55
54
  rayon = "1.11"
@@ -11,17 +11,17 @@ pub(crate) fn register_bytecode_native_modules(vm: &mut tishlang_vm::Vm) {
11
11
  let mut om = ObjectMap::with_capacity(8);
12
12
  om.insert(
13
13
  Arc::from("per_worker_client"),
14
- Value::native(tish_pg::per_worker_client),
14
+ Value::native(tishlang_pg::per_worker_client),
15
15
  );
16
- om.insert(Arc::from("connect"), Value::native(tish_pg::connect));
17
- om.insert(Arc::from("prepare"), Value::native(tish_pg::prepare));
16
+ om.insert(Arc::from("connect"), Value::native(tishlang_pg::connect));
17
+ om.insert(Arc::from("prepare"), Value::native(tishlang_pg::prepare));
18
18
  om.insert(
19
19
  Arc::from("query_prepared"),
20
- Value::native(tish_pg::query_prepared),
20
+ Value::native(tishlang_pg::query_prepared),
21
21
  );
22
- om.insert(Arc::from("query_all"), Value::native(tish_pg::query_all));
23
- om.insert(Arc::from("migrate"), Value::native(tish_pg::migrate));
24
- om.insert(Arc::from("close"), Value::native(tish_pg::close));
22
+ om.insert(Arc::from("query_all"), Value::native(tishlang_pg::query_all));
23
+ om.insert(Arc::from("migrate"), Value::native(tishlang_pg::migrate));
24
+ om.insert(Arc::from("close"), Value::native(tishlang_pg::close));
25
25
  vm.register_native_module("cargo:tish_pg", om);
26
26
  }
27
27
 
@@ -0,0 +1,34 @@
1
+ [package]
2
+ name = "tishlang_pg"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "PostgreSQL driver for the Tish native runtime — tokio-postgres + deadpool"
6
+ license-file = { workspace = true }
7
+ repository = { workspace = true }
8
+
9
+ [lib]
10
+ name = "tishlang_pg"
11
+ crate-type = ["rlib"]
12
+
13
+ [features]
14
+ default = ["tish-bindings"]
15
+ # Exposes sync `pub fn(args: &[Value]) -> Value` wrappers for the bytecode VM
16
+ # (`cargo:tish_pg` module URL, registered by the `tishlang` CLI when built with `pg`).
17
+ tish-bindings = ["dep:tishlang_runtime", "dep:once_cell", "dep:slab"]
18
+
19
+ [dependencies]
20
+ tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
21
+ tokio-postgres = { version = "0.7", features = ["with-serde_json-1", "with-uuid-1", "with-chrono-0_4"] }
22
+ futures = "0.3"
23
+ deadpool = "0.12"
24
+ deadpool-postgres = "0.14"
25
+ serde = { version = "1", features = ["derive"] }
26
+ serde_json = "1"
27
+ thiserror = "1"
28
+ url = "2"
29
+ tishlang_runtime = { path = "../tish_runtime", version = ">=0.1", optional = true, features = ["send-values"] }
30
+ once_cell = { version = "1", optional = true }
31
+ slab = { version = "0.4", optional = true }
32
+
33
+ [dev-dependencies]
34
+ tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
@@ -0,0 +1,38 @@
1
+ # tishlang_pg
2
+
3
+ **PostgreSQL for Tish** — a Rust library (`rlib`) that **Tish-compiled programs** use: Tish compiles to native Rust, and this crate is a normal Rust dependency of that output, so **authors still write `.tish`**.
4
+
5
+ ## What this is **not**
6
+
7
+ - **No Node.js** / no N-API in this crate
8
+ - No JavaScript runtime in the driver itself
9
+
10
+ ## What this **is**
11
+
12
+ - **`tokio-postgres`** + **`deadpool-postgres`** for the wire protocol and pooling.
13
+ - A **Rust API** (`PgPool`, `PoolConfig`, `QueryResult`) plus Tish-facing bindings (`cargo:tish_pg`) used when the CLI is built with the **`pg`** feature.
14
+
15
+ ## From `.tish` (npm package)
16
+
17
+ Install the scoped package and import the same surface as the standalone `tish-pg` repo used to ship:
18
+
19
+ ```tish
20
+ import { connect, queryPrepared, prepare, close } from '@tishlang/pg'
21
+ ```
22
+
23
+ Requires **`tish run` / `tish build` with the `pg` feature** (included in the default `full` feature set).
24
+
25
+ ## Building in this workspace
26
+
27
+ From the `tish` repo root:
28
+
29
+ ```bash
30
+ cargo build -p tishlang_pg
31
+ cargo test -p tishlang_pg
32
+ ```
33
+
34
+ See the workspace [`justfile`](../../justfile) for common tasks.
35
+
36
+ ## License
37
+
38
+ Same as the parent workspace (`LICENSE` at repo root).
@@ -0,0 +1,52 @@
1
+ use std::fmt::Write as _;
2
+ use std::error::Error as StdError;
3
+
4
+ use deadpool::managed::PoolError;
5
+ use thiserror::Error;
6
+
7
+ /// `tokio_postgres::Error`’s [`Display`] is often just `"db error"` for server
8
+ /// faults; the real text lives on [`tokio_postgres::Error::as_db_error`].
9
+ pub fn format_pg_error(e: &tokio_postgres::Error) -> String {
10
+ if let Some(db) = e.as_db_error() {
11
+ let mut s = format!("{} [{}]", db.message(), db.code().code());
12
+ if let Some(d) = db.detail() {
13
+ let _ = write!(s, " ({})", d);
14
+ }
15
+ if let Some(h) = db.hint() {
16
+ let _ = write!(s, " hint: {}", h);
17
+ }
18
+ return s;
19
+ }
20
+ let mut out = e.to_string();
21
+ if let Some(src) = e.source() {
22
+ let _ = write!(out, " ({})", src);
23
+ }
24
+ out
25
+ }
26
+
27
+ pub fn format_tish_pg_error(e: &TishPgError) -> String {
28
+ match e {
29
+ TishPgError::Postgres(pg) => format_pg_error(pg),
30
+ TishPgError::Pool(pe) => match pe {
31
+ PoolError::Backend(pg) => format_pg_error(pg),
32
+ _ => pe.to_string(),
33
+ },
34
+ _ => e.to_string(),
35
+ }
36
+ }
37
+
38
+ #[derive(Debug, Error)]
39
+ pub enum TishPgError {
40
+ #[error("invalid connection string: {0}")]
41
+ BadConnectionString(String),
42
+ #[error("invalid query parameter: {0}")]
43
+ BadParam(String),
44
+ #[error("postgres: {0}")]
45
+ Postgres(#[from] tokio_postgres::Error),
46
+ #[error("pool: {0}")]
47
+ Pool(#[from] deadpool::managed::PoolError<tokio_postgres::Error>),
48
+ #[error("build pool: {0}")]
49
+ Build(#[from] deadpool_postgres::BuildError),
50
+ }
51
+
52
+ pub type Result<T> = std::result::Result<T, TishPgError>;
@@ -0,0 +1,967 @@
1
+ //! **tishlang_pg** — PostgreSQL for the **Tish native runtime** only.
2
+ //!
3
+ //! - **No Node.js**, **no N-API**, **no JavaScript** in this crate or its distribution.
4
+ //! - Uses **Rust** [`tokio-postgres`](https://docs.rs/tokio-postgres) + [`deadpool-postgres`](https://docs.rs/deadpool-postgres) for protocol and pooling.
5
+ //! - The **Tish compiler / runtime** (`tishlang/tish`) links this `rlib` and exposes a `pg` (or `tish_pg`) module to **`.tish` application code** — same *call shape* as node-postgres where practical (`Pool`, `query`, `rows`, `rowCount`).
6
+ //!
7
+ //! Application authors write **only `.tish`**; they never touch this Rust API directly once bindings exist upstream.
8
+ use tishlang_runtime::VmRef;
9
+
10
+ mod error;
11
+ pub use error::{format_pg_error, format_tish_pg_error, Result, TishPgError};
12
+
13
+ use deadpool_postgres::{Manager, Pool, Runtime};
14
+ use serde::{Deserialize, Serialize};
15
+ use serde_json::{json, Map, Value as JsonValue};
16
+ use tokio_postgres::{types::ToSql, NoTls, Row};
17
+
18
+ /// Configuration mirroring the common `pg` / `node-postgres` `Pool` constructor shape.
19
+ #[derive(Debug, Clone, Deserialize, Serialize)]
20
+ pub struct PoolConfig {
21
+ pub connection_string: String,
22
+ #[serde(default)]
23
+ pub max: Option<u32>,
24
+ }
25
+
26
+ #[derive(Debug, Clone, Serialize)]
27
+ pub struct FieldInfo {
28
+ pub name: String,
29
+ pub data_type_id: i32,
30
+ }
31
+
32
+ #[derive(Debug, Clone, Serialize)]
33
+ pub struct QueryResult {
34
+ pub rows: Vec<JsonValue>,
35
+ pub row_count: u32,
36
+ pub fields: Vec<FieldInfo>,
37
+ }
38
+
39
+ /// Rust-side pool; Tish maps this to `Pool` in user code.
40
+ #[derive(Clone)]
41
+ pub struct PgPool {
42
+ inner: Pool,
43
+ }
44
+
45
+ fn parse_connection_string(cs: &str) -> std::result::Result<tokio_postgres::Config, String> {
46
+ let u = url::Url::parse(cs).map_err(|e| e.to_string())?;
47
+ if u.scheme() != "postgres" && u.scheme() != "postgresql" {
48
+ return Err("URL must use postgres:// or postgresql://".into());
49
+ }
50
+ let mut cfg = tokio_postgres::Config::new();
51
+ if let Some(host) = u.host_str() {
52
+ cfg.host(host);
53
+ }
54
+ if let Some(p) = u.port() {
55
+ cfg.port(p);
56
+ }
57
+ let path = u.path().trim_start_matches('/');
58
+ if !path.is_empty() {
59
+ cfg.dbname(path);
60
+ }
61
+ if !u.username().is_empty() {
62
+ cfg.user(u.username());
63
+ }
64
+ if let Some(pw) = u.password() {
65
+ cfg.password(pw);
66
+ }
67
+ Ok(cfg)
68
+ }
69
+
70
+ fn params_to_sql(values: &[JsonValue]) -> Result<Vec<Box<dyn ToSql + Sync + Send>>> {
71
+ let mut out: Vec<Box<dyn ToSql + Sync + Send>> = Vec::with_capacity(values.len());
72
+ for v in values {
73
+ let b: Box<dyn ToSql + Sync + Send> = match v {
74
+ JsonValue::Null => Box::new(Option::<String>::None),
75
+ JsonValue::Bool(b) => Box::new(*b),
76
+ JsonValue::Number(n) => {
77
+ // tokio-postgres type-checks against the prepared statement's
78
+ // column OIDs. An i64 param against an INT4 column or an f64
79
+ // param against INT4 both error with WrongType. Tish stores
80
+ // all numbers as f64, so we need to detect "whole number in
81
+ // i32 range" and downgrade to i32 so TFB's world.id / world.randomnumber
82
+ // (both INT4) bind correctly. Larger ints fall through to i64
83
+ // (matches INT8). Fractional values stay as f64 (matches FLOAT8).
84
+ if let Some(i) = n.as_i64() {
85
+ if i >= i32::MIN as i64 && i <= i32::MAX as i64 {
86
+ Box::new(i as i32)
87
+ } else {
88
+ Box::new(i)
89
+ }
90
+ } else if let Some(u) = n.as_u64() {
91
+ Box::new(u as i64)
92
+ } else if let Some(f) = n.as_f64() {
93
+ if f.fract() == 0.0
94
+ && f >= i32::MIN as f64
95
+ && f <= i32::MAX as f64
96
+ {
97
+ Box::new(f as i32)
98
+ } else if f.fract() == 0.0
99
+ && f >= i64::MIN as f64
100
+ && f <= i64::MAX as f64
101
+ {
102
+ Box::new(f as i64)
103
+ } else {
104
+ Box::new(f)
105
+ }
106
+ } else {
107
+ return Err(TishPgError::BadParam(
108
+ "invalid JSON number for SQL param".into(),
109
+ ));
110
+ }
111
+ }
112
+ JsonValue::String(s) => Box::new(s.clone()),
113
+ JsonValue::Array(_) | JsonValue::Object(_) => Box::new(tokio_postgres::types::Json(v.clone())),
114
+ };
115
+ out.push(b);
116
+ }
117
+ Ok(out)
118
+ }
119
+
120
+ /// Convert a Postgres `Row` directly into a Tish `Value::Object`,
121
+ /// skipping the `JsonValue` intermediate that `row_to_object` produces.
122
+ ///
123
+ /// Two wins on the hot path:
124
+ ///
125
+ /// 1. **One pass instead of two.** The blanket `Row → JsonValue → Value`
126
+ /// path allocated a `serde_json::Map<String, JsonValue>` and then
127
+ /// re-walked it into an `ObjectMap<Arc<str>, Value>`. This produces
128
+ /// the `ObjectMap` in one shot.
129
+ /// 2. **Interned column names.** TFB hits the same `id`/`randomnumber`/
130
+ /// `message` keys ~140k times per second; the per-row `Arc::from(name)`
131
+ /// became a measurable allocator load. We cache one `Arc<str>` per
132
+ /// `(stmt-prepared-once)` column name in a thread-local, so subsequent
133
+ /// rows reuse the same `Arc` (one atomic ref-count bump, no allocation).
134
+ ///
135
+ /// Used by [`PerWorkerClient::query_prepared_to_value`] and
136
+ /// [`PerWorkerClient::query_batch_to_values`] (added below).
137
+ fn row_to_value_direct(row: &Row) -> tishlang_runtime::Value {
138
+ use std::cell::RefCell;
139
+ use std::collections::HashMap;
140
+ use std::sync::Arc as StdArcInner;
141
+ use tishlang_runtime::ObjectMap;
142
+ use tishlang_runtime::Value as RtValue;
143
+ use tokio_postgres::types::Type;
144
+
145
+ thread_local! {
146
+ // Per-worker thread cache of column-name interned `Arc<str>`s.
147
+ // Bounded by total distinct PG column names the app prepares
148
+ // statements for, i.e. tiny — so unconditional retention is fine.
149
+ static KEY_CACHE: RefCell<HashMap<String, StdArcInner<str>>> =
150
+ RefCell::new(HashMap::new());
151
+ }
152
+
153
+ fn intern_key(name: &str) -> StdArcInner<str> {
154
+ KEY_CACHE.with(|cache| {
155
+ let mut cache = cache.borrow_mut();
156
+ if let Some(k) = cache.get(name) {
157
+ return StdArcInner::clone(k);
158
+ }
159
+ let k: StdArcInner<str> = StdArcInner::from(name);
160
+ cache.insert(name.to_string(), StdArcInner::clone(&k));
161
+ k
162
+ })
163
+ }
164
+
165
+ let cols = row.columns();
166
+ let mut om = ObjectMap::with_capacity(cols.len());
167
+ for (i, col) in cols.iter().enumerate() {
168
+ let key = intern_key(col.name());
169
+ let v: RtValue = match *col.type_() {
170
+ Type::INT2 => row
171
+ .try_get::<_, Option<i16>>(i)
172
+ .ok()
173
+ .flatten()
174
+ .map(|n| RtValue::Number(n as f64))
175
+ .unwrap_or(RtValue::Null),
176
+ Type::INT4 | Type::OID => row
177
+ .try_get::<_, Option<i32>>(i)
178
+ .ok()
179
+ .flatten()
180
+ .map(|n| RtValue::Number(n as f64))
181
+ .unwrap_or(RtValue::Null),
182
+ Type::INT8 => row
183
+ .try_get::<_, Option<i64>>(i)
184
+ .ok()
185
+ .flatten()
186
+ .map(|n| RtValue::Number(n as f64))
187
+ .unwrap_or(RtValue::Null),
188
+ Type::FLOAT4 => row
189
+ .try_get::<_, Option<f32>>(i)
190
+ .ok()
191
+ .flatten()
192
+ .map(|n| RtValue::Number(n as f64))
193
+ .unwrap_or(RtValue::Null),
194
+ Type::FLOAT8 => row
195
+ .try_get::<_, Option<f64>>(i)
196
+ .ok()
197
+ .flatten()
198
+ .map(RtValue::Number)
199
+ .unwrap_or(RtValue::Null),
200
+ Type::BOOL => row
201
+ .try_get::<_, Option<bool>>(i)
202
+ .ok()
203
+ .flatten()
204
+ .map(RtValue::Bool)
205
+ .unwrap_or(RtValue::Null),
206
+ Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => row
207
+ .try_get::<_, Option<&str>>(i)
208
+ .ok()
209
+ .flatten()
210
+ .map(|s| RtValue::String(StdArcInner::from(s)))
211
+ .unwrap_or(RtValue::Null),
212
+ _ => {
213
+ // Anything else goes through the JSON path for backwards
214
+ // compat. Hot TFB rows never hit this branch.
215
+ if let Ok(s) = row.try_get::<_, Option<String>>(i) {
216
+ s.map(|s| RtValue::String(StdArcInner::from(s.as_str())))
217
+ .unwrap_or(RtValue::Null)
218
+ } else {
219
+ RtValue::Null
220
+ }
221
+ }
222
+ };
223
+ om.insert(key, v);
224
+ }
225
+ RtValue::Object(VmRef::new(om))
226
+ }
227
+
228
+ fn row_to_object(row: &Row) -> Result<JsonValue> {
229
+ use tokio_postgres::types::Type;
230
+ let mut map = Map::with_capacity(row.columns().len());
231
+ for (i, col) in row.columns().iter().enumerate() {
232
+ let name = col.name().to_string();
233
+ // Phase-2 item 10: type-directed decode for the common Postgres wire
234
+ // types so we skip the text->parse->int detour that the blind
235
+ // try_get cascade triggered. Hot TFB columns are INT4 (world.id,
236
+ // world.randomnumber) and TEXT/VARCHAR (fortune.message).
237
+ let val: JsonValue = match *col.type_() {
238
+ Type::INT2 => row
239
+ .try_get::<_, Option<i16>>(i)
240
+ .map(|v| json!(v))
241
+ .unwrap_or(JsonValue::Null),
242
+ Type::INT4 | Type::OID => row
243
+ .try_get::<_, Option<i32>>(i)
244
+ .map(|v| json!(v))
245
+ .unwrap_or(JsonValue::Null),
246
+ Type::INT8 => row
247
+ .try_get::<_, Option<i64>>(i)
248
+ .map(|v| json!(v))
249
+ .unwrap_or(JsonValue::Null),
250
+ Type::FLOAT4 => row
251
+ .try_get::<_, Option<f32>>(i)
252
+ .map(|v| json!(v.map(|f| f as f64)))
253
+ .unwrap_or(JsonValue::Null),
254
+ Type::FLOAT8 => row
255
+ .try_get::<_, Option<f64>>(i)
256
+ .map(|v| json!(v))
257
+ .unwrap_or(JsonValue::Null),
258
+ Type::BOOL => row
259
+ .try_get::<_, Option<bool>>(i)
260
+ .map(|v| json!(v))
261
+ .unwrap_or(JsonValue::Null),
262
+ Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => row
263
+ .try_get::<_, Option<String>>(i)
264
+ .map(|v| json!(v))
265
+ .unwrap_or(JsonValue::Null),
266
+ Type::JSON | Type::JSONB => row
267
+ .try_get::<_, Option<JsonValue>>(i)
268
+ .unwrap_or(None)
269
+ .unwrap_or(JsonValue::Null),
270
+ Type::BYTEA => row
271
+ .try_get::<_, Option<Vec<u8>>>(i)
272
+ .map(|v| json!(v))
273
+ .unwrap_or(JsonValue::Null),
274
+ _ => {
275
+ // Fall back to the old try-cascade for anything we haven't
276
+ // listed explicitly yet.
277
+ if let Ok(v) = row.try_get::<_, Option<String>>(i) {
278
+ json!(v)
279
+ } else if let Ok(v) = row.try_get::<_, Option<i64>>(i) {
280
+ json!(v)
281
+ } else if let Ok(v) = row.try_get::<_, Option<f64>>(i) {
282
+ json!(v)
283
+ } else if let Ok(v) = row.try_get::<_, Option<bool>>(i) {
284
+ json!(v)
285
+ } else if let Ok(v) = row.try_get::<_, Option<JsonValue>>(i) {
286
+ v.unwrap_or(JsonValue::Null)
287
+ } else if let Ok(v) = row.try_get::<_, Option<Vec<u8>>>(i) {
288
+ json!(v)
289
+ } else {
290
+ JsonValue::String(format!("<decode:{}>", col.type_().name()))
291
+ }
292
+ }
293
+ };
294
+ map.insert(name, val);
295
+ }
296
+ Ok(JsonValue::Object(map))
297
+ }
298
+
299
+ impl PgPool {
300
+ pub async fn connect(cfg: PoolConfig) -> Result<Self> {
301
+ let pg_cfg = parse_connection_string(&cfg.connection_string)
302
+ .map_err(TishPgError::BadConnectionString)?;
303
+ let mgr = Manager::new(pg_cfg, NoTls);
304
+ let mut b = Pool::builder(mgr).runtime(Runtime::Tokio1);
305
+ if let Some(m) = cfg.max {
306
+ b = b.max_size(m as usize);
307
+ }
308
+ let inner = b.build()?;
309
+ Ok(Self { inner })
310
+ }
311
+
312
+ /// Same logical contract as `pool.query(text, params)` in node-postgres.
313
+ pub async fn query(&self, text: &str, params: &[JsonValue]) -> Result<QueryResult> {
314
+ let sql_values = params_to_sql(params)?;
315
+ let refs: Vec<&(dyn ToSql + Sync)> = sql_values
316
+ .iter()
317
+ .map(|b| b.as_ref() as &(dyn ToSql + Sync))
318
+ .collect();
319
+
320
+ let client = self.inner.get().await?;
321
+ let rows = client.query(text, &refs[..]).await?;
322
+
323
+ let mut fields = Vec::new();
324
+ if let Some(first) = rows.first() {
325
+ for col in first.columns().iter() {
326
+ fields.push(FieldInfo {
327
+ name: col.name().to_string(),
328
+ data_type_id: col.type_().oid() as i32,
329
+ });
330
+ }
331
+ }
332
+
333
+ let mut out_rows = Vec::with_capacity(rows.len());
334
+ for r in &rows {
335
+ out_rows.push(row_to_object(r)?);
336
+ }
337
+
338
+ Ok(QueryResult {
339
+ row_count: rows.len() as u32,
340
+ rows: out_rows,
341
+ fields,
342
+ })
343
+ }
344
+
345
+ /// Close the pool (mirrors `pool.end()`).
346
+ pub fn close(&self) {
347
+ self.inner.close();
348
+ }
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // PerWorkerClient + prepared-statement surface (Phase-1 item 3)
353
+ // ---------------------------------------------------------------------------
354
+ //
355
+ // The Tish bench needs a dedicated Postgres connection per HTTP worker so the
356
+ // hot path is:
357
+ //
358
+ // worker 0 -> client 0 (1 TCP socket) -> pipelined Parse/Bind/Execute/Sync
359
+ // worker N -> client N
360
+ //
361
+ // `tokio-postgres` pipelines automatically when multiple futures on the same
362
+ // `Client` are polled concurrently, so we expose a batch-query primitive that
363
+ // creates N futures under the hood and `try_join_all`s them.
364
+
365
+ use std::sync::Arc as StdArc;
366
+ use tokio::task::JoinHandle;
367
+ use tokio_postgres::{Client, Statement};
368
+
369
+ /// Dedicated tokio-postgres client. Cheap to clone (internal Arc).
370
+ #[derive(Clone)]
371
+ pub struct PerWorkerClient {
372
+ inner: StdArc<Client>,
373
+ // Background task that drives the connection; we keep it alive for the
374
+ // lifetime of the client.
375
+ _driver: StdArc<JoinHandle<()>>,
376
+ }
377
+
378
+ impl PerWorkerClient {
379
+ /// Open a single direct connection (no pool).
380
+ pub async fn connect(connection_string: &str) -> Result<Self> {
381
+ let cfg = parse_connection_string(connection_string)
382
+ .map_err(TishPgError::BadConnectionString)?;
383
+ let (client, connection) = cfg.connect(NoTls).await?;
384
+ let driver = tokio::spawn(async move {
385
+ if let Err(e) = connection.await {
386
+ eprintln!("[tish_pg] connection driver exited: {}", e);
387
+ }
388
+ });
389
+ Ok(Self {
390
+ inner: StdArc::new(client),
391
+ _driver: StdArc::new(driver),
392
+ })
393
+ }
394
+
395
+ pub fn client(&self) -> &Client {
396
+ &self.inner
397
+ }
398
+
399
+ /// Prepare a named statement. Handle is cheap to clone.
400
+ pub async fn prepare(&self, text: &str) -> Result<Statement> {
401
+ Ok(self.inner.prepare(text).await?)
402
+ }
403
+
404
+ /// Run one prepared query -> rows as JSON.
405
+ pub async fn query_prepared(
406
+ &self,
407
+ stmt: &Statement,
408
+ params: &[JsonValue],
409
+ ) -> Result<QueryResult> {
410
+ let sql_values = params_to_sql(params)?;
411
+ let refs: Vec<&(dyn ToSql + Sync)> = sql_values
412
+ .iter()
413
+ .map(|b| b.as_ref() as &(dyn ToSql + Sync))
414
+ .collect();
415
+ let rows = self.inner.query(stmt, &refs[..]).await?;
416
+ let mut fields = Vec::new();
417
+ if let Some(first) = rows.first() {
418
+ for col in first.columns().iter() {
419
+ fields.push(FieldInfo {
420
+ name: col.name().to_string(),
421
+ data_type_id: col.type_().oid() as i32,
422
+ });
423
+ }
424
+ }
425
+ let mut out_rows = Vec::with_capacity(rows.len());
426
+ for r in &rows {
427
+ out_rows.push(row_to_object(r)?);
428
+ }
429
+ Ok(QueryResult {
430
+ row_count: rows.len() as u32,
431
+ rows: out_rows,
432
+ fields,
433
+ })
434
+ }
435
+
436
+ /// Fire N prepared queries against this client concurrently and await them
437
+ /// together. Triggers `tokio-postgres`'s automatic pipelining: all Parse/
438
+ /// Bind/Execute/Sync messages are written back-to-back in one TCP batch
439
+ /// and the server executes them sequentially while the client never waits
440
+ /// for per-query round-trips.
441
+ pub async fn query_batch(
442
+ &self,
443
+ specs: Vec<(Statement, Vec<JsonValue>)>,
444
+ ) -> Result<Vec<QueryResult>> {
445
+ use futures::future::try_join_all;
446
+ let futs: Vec<_> = specs
447
+ .into_iter()
448
+ .map(|(stmt, params)| {
449
+ let client = StdArc::clone(&self.inner);
450
+ async move {
451
+ let sql_values = params_to_sql(&params)?;
452
+ let refs: Vec<&(dyn ToSql + Sync)> = sql_values
453
+ .iter()
454
+ .map(|b| b.as_ref() as &(dyn ToSql + Sync))
455
+ .collect();
456
+ let rows = client.query(&stmt, &refs[..]).await?;
457
+ let mut out_rows = Vec::with_capacity(rows.len());
458
+ for r in &rows {
459
+ out_rows.push(row_to_object(r)?);
460
+ }
461
+ Ok::<_, TishPgError>(QueryResult {
462
+ row_count: rows.len() as u32,
463
+ rows: out_rows,
464
+ fields: Vec::new(),
465
+ })
466
+ }
467
+ })
468
+ .collect();
469
+ try_join_all(futs).await
470
+ }
471
+
472
+ /// Like [`query_prepared`] but emits `Vec<tishlang_runtime::Value>`
473
+ /// directly using the [`row_to_value_direct`] fast path — no
474
+ /// `serde_json::Value` intermediate, no per-row column-name `Arc`
475
+ /// allocations. The hot TFB call site for `/db` and `/queries`.
476
+ pub async fn query_prepared_to_values(
477
+ &self,
478
+ stmt: &Statement,
479
+ params: &[JsonValue],
480
+ ) -> Result<Vec<tishlang_runtime::Value>> {
481
+ let sql_values = params_to_sql(params)?;
482
+ let refs: Vec<&(dyn ToSql + Sync)> = sql_values
483
+ .iter()
484
+ .map(|b| b.as_ref() as &(dyn ToSql + Sync))
485
+ .collect();
486
+ let rows = self.inner.query(stmt, &refs[..]).await?;
487
+ let mut out = Vec::with_capacity(rows.len());
488
+ for r in &rows {
489
+ out.push(row_to_value_direct(r));
490
+ }
491
+ Ok(out)
492
+ }
493
+
494
+ /// Pipelined batch of typed queries — the equivalent of
495
+ /// [`query_batch`] for the typed (no-JSON) path.
496
+ pub async fn query_batch_to_values(
497
+ &self,
498
+ specs: Vec<(Statement, Vec<JsonValue>)>,
499
+ ) -> Result<Vec<Vec<tishlang_runtime::Value>>> {
500
+ use futures::future::try_join_all;
501
+ let futs: Vec<_> = specs
502
+ .into_iter()
503
+ .map(|(stmt, params)| {
504
+ let client = StdArc::clone(&self.inner);
505
+ async move {
506
+ let sql_values = params_to_sql(&params)?;
507
+ let refs: Vec<&(dyn ToSql + Sync)> = sql_values
508
+ .iter()
509
+ .map(|b| b.as_ref() as &(dyn ToSql + Sync))
510
+ .collect();
511
+ let rows = client.query(&stmt, &refs[..]).await?;
512
+ let mut out = Vec::with_capacity(rows.len());
513
+ for r in &rows {
514
+ out.push(row_to_value_direct(r));
515
+ }
516
+ Ok::<_, TishPgError>(out)
517
+ }
518
+ })
519
+ .collect();
520
+ try_join_all(futs).await
521
+ }
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Sync facade for the `cargo:tish_pg` Tish import (feature = tish-bindings)
526
+ // ---------------------------------------------------------------------------
527
+
528
+ #[cfg(feature = "tish-bindings")]
529
+ mod tish_sync {
530
+ use super::*;
531
+ use once_cell::sync::Lazy;
532
+ use slab::Slab;
533
+ use std::sync::Mutex;
534
+ use tishlang_runtime::Value as TishValue;
535
+ use tokio::runtime::Runtime as TokioRuntime;
536
+
537
+ static RT: Lazy<TokioRuntime> = Lazy::new(|| {
538
+ TokioRuntime::new().expect("tish_pg: failed to build tokio runtime")
539
+ });
540
+ // `RwLock` (not `Mutex`) on the registries: hot path is read-only —
541
+ // every query does `get_client(id)` + `get_statement(id)`. Inserts
542
+ // happen once per `connect`/`prepare` at startup. With multiple HTTP
543
+ // worker threads each doing thousands of QPS, the prior `Mutex<Slab>`
544
+ // serialised every query through one global lock; `RwLock` lets all
545
+ // concurrent reads run lock-free against each other.
546
+ use std::sync::RwLock;
547
+ static CLIENTS: Lazy<RwLock<Slab<PerWorkerClient>>> =
548
+ Lazy::new(|| RwLock::new(Slab::new()));
549
+ static STATEMENTS: Lazy<RwLock<Slab<(usize, Statement)>>> =
550
+ Lazy::new(|| RwLock::new(Slab::new()));
551
+
552
+ /// Drive a future on our tokio runtime without panicking when called from
553
+ /// inside another runtime's worker thread.
554
+ ///
555
+ /// Fast path: the Tish HTTP handler runs on the VM dispatcher thread,
556
+ /// which is NOT inside a tokio runtime — so we can enter `RT.block_on`
557
+ /// directly, no thread spawn. Measured cost: ~50-100μs per call saved
558
+ /// (and a thread creation per request is what was capping /db RPS).
559
+ ///
560
+ /// Slow path: if we detect an ambient tokio runtime (e.g. someone is
561
+ /// calling us from inside an async context like Tish's top-level
562
+ /// `await`), we fall back to a scoped thread to avoid the
563
+ /// "Cannot start a runtime from within a runtime" panic.
564
+ fn block_on<F>(fut: F) -> F::Output
565
+ where
566
+ F: std::future::Future + Send,
567
+ F::Output: Send,
568
+ {
569
+ // Whether we're inside a tokio runtime never changes for a given
570
+ // OS thread — Tish runs handlers on the VM dispatcher thread,
571
+ // which is a plain `std::thread` (so the answer is "no, no
572
+ // ambient runtime"). Cache the result per-thread so the
573
+ // `try_current()` syscall-ish call doesn't repeat thousands of
574
+ // times per second per worker.
575
+ use std::cell::Cell;
576
+ thread_local! {
577
+ static AMBIENT_RT: Cell<Option<bool>> = const { Cell::new(None) };
578
+ }
579
+ let in_ambient = AMBIENT_RT.with(|c| {
580
+ if let Some(v) = c.get() {
581
+ return v;
582
+ }
583
+ let v = tokio::runtime::Handle::try_current().is_ok();
584
+ c.set(Some(v));
585
+ v
586
+ });
587
+ if in_ambient {
588
+ // Ambient runtime present — spawn a thread so RT.block_on does
589
+ // not nest.
590
+ return std::thread::scope(|s| {
591
+ let (tx, rx) = std::sync::mpsc::channel();
592
+ s.spawn(move || {
593
+ let out = RT.block_on(fut);
594
+ let _ = tx.send(out);
595
+ });
596
+ rx.recv().expect("tish_pg::block_on thread panicked")
597
+ });
598
+ }
599
+ // Hot path: we're on a plain OS thread (the Tish VM dispatcher),
600
+ // enter tokio directly.
601
+ RT.block_on(fut)
602
+ }
603
+
604
+ fn tish_to_json(v: &TishValue) -> JsonValue {
605
+ match v {
606
+ TishValue::Null => JsonValue::Null,
607
+ TishValue::Bool(b) => JsonValue::Bool(*b),
608
+ TishValue::Number(n) => serde_json::Number::from_f64(*n)
609
+ .map(JsonValue::Number)
610
+ .unwrap_or(JsonValue::Null),
611
+ TishValue::String(s) => JsonValue::String(s.to_string()),
612
+ TishValue::Array(a) => {
613
+ JsonValue::Array(a.borrow().iter().map(tish_to_json).collect())
614
+ }
615
+ TishValue::Object(o) => {
616
+ let mut m = serde_json::Map::new();
617
+ for (k, v) in o.borrow().iter() {
618
+ m.insert(k.to_string(), tish_to_json(v));
619
+ }
620
+ JsonValue::Object(m)
621
+ }
622
+ _ => JsonValue::Null,
623
+ }
624
+ }
625
+
626
+ fn json_to_tish(v: JsonValue) -> TishValue {
627
+ use std::cell::RefCell;
628
+ use std::rc::Rc;
629
+ use std::sync::Arc;
630
+ use tishlang_runtime::ObjectMap;
631
+ match v {
632
+ JsonValue::Null => TishValue::Null,
633
+ JsonValue::Bool(b) => TishValue::Bool(b),
634
+ JsonValue::Number(n) => TishValue::Number(n.as_f64().unwrap_or(0.0)),
635
+ JsonValue::String(s) => TishValue::String(s.into()),
636
+ JsonValue::Array(a) => {
637
+ let mut out = Vec::with_capacity(a.len());
638
+ for item in a {
639
+ out.push(json_to_tish(item));
640
+ }
641
+ TishValue::Array(VmRef::new(out))
642
+ }
643
+ JsonValue::Object(m) => {
644
+ // Pre-allocate ObjectMap capacity so HashMap doesn't rehash
645
+ // on every insert. Common TFB rows are 2 columns (id,
646
+ // randomnumber or id, message).
647
+ let mut om = ObjectMap::with_capacity(m.len());
648
+ for (k, v) in m {
649
+ om.insert(Arc::from(k), json_to_tish(v));
650
+ }
651
+ TishValue::Object(VmRef::new(om))
652
+ }
653
+ }
654
+ }
655
+
656
+ fn tish_err(msg: impl Into<String>) -> TishValue {
657
+ use std::sync::Arc;
658
+ use tishlang_runtime::ObjectMap;
659
+ let mut om = ObjectMap::with_capacity(2);
660
+ om.insert(Arc::from("error"), TishValue::String(msg.into().into()));
661
+ om.insert(Arc::from("ok"), TishValue::Bool(false));
662
+ TishValue::Object(VmRef::new(om))
663
+ }
664
+
665
+ fn rows_to_value(res: QueryResult) -> TishValue {
666
+ use std::cell::RefCell;
667
+ use std::rc::Rc;
668
+ TishValue::Array(VmRef::new(
669
+ res.rows.into_iter().map(json_to_tish).collect(),
670
+ ))
671
+ }
672
+
673
+ /// `perWorkerClient(connection_string) -> client_handle` (blocking).
674
+ pub fn per_worker_client(args: &[TishValue]) -> TishValue {
675
+ let cs = match args.first() {
676
+ Some(TishValue::String(s)) => s.to_string(),
677
+ _ => return tish_err("perWorkerClient: expected connection string"),
678
+ };
679
+ match block_on(PerWorkerClient::connect(&cs)) {
680
+ Ok(c) => {
681
+ let mut g = CLIENTS.write().unwrap();
682
+ let id = g.insert(c);
683
+ TishValue::Number(id as f64)
684
+ }
685
+ Err(e) => tish_err(format!("perWorkerClient: {}", crate::format_tish_pg_error(&e))),
686
+ }
687
+ }
688
+
689
+ /// `connect(options) -> client_handle`.
690
+ /// Aliased to per_worker_client for now; Pool-based variant is still
691
+ /// reachable via the async Rust API.
692
+ pub fn connect(args: &[TishValue]) -> TishValue {
693
+ // Accept either a plain connection string or `{ connectionString }`.
694
+ let cs = match args.first() {
695
+ Some(TishValue::String(s)) => s.to_string(),
696
+ Some(TishValue::Object(obj)) => {
697
+ use std::sync::Arc;
698
+ let b = obj.borrow();
699
+ match b.get(&Arc::from("connectionString")) {
700
+ Some(TishValue::String(s)) => s.to_string(),
701
+ _ => return tish_err("connect: options.connectionString missing"),
702
+ }
703
+ }
704
+ _ => return tish_err("connect: expected connection string or options"),
705
+ };
706
+ match block_on(PerWorkerClient::connect(&cs)) {
707
+ Ok(c) => {
708
+ let mut g = CLIENTS.write().unwrap();
709
+ let id = g.insert(c);
710
+ TishValue::Number(id as f64)
711
+ }
712
+ Err(e) => tish_err(format!("connect: {}", crate::format_tish_pg_error(&e))),
713
+ }
714
+ }
715
+
716
+ fn get_client(id: f64) -> Option<PerWorkerClient> {
717
+ let g = CLIENTS.read().unwrap();
718
+ g.get(id as usize).cloned()
719
+ }
720
+
721
+ fn get_statement(id: f64) -> Option<(usize, Statement)> {
722
+ let g = STATEMENTS.read().unwrap();
723
+ g.get(id as usize).cloned()
724
+ }
725
+
726
+ /// `prepare(client_handle, sql) -> statement_handle`.
727
+ pub fn prepare(args: &[TishValue]) -> TishValue {
728
+ let Some(TishValue::Number(client_id)) = args.first() else {
729
+ return tish_err("prepare: expected (client, sql)");
730
+ };
731
+ let Some(TishValue::String(sql)) = args.get(1) else {
732
+ return tish_err("prepare: expected (client, sql)");
733
+ };
734
+ let Some(client) = get_client(*client_id) else {
735
+ return tish_err("prepare: unknown client handle");
736
+ };
737
+ let sql = sql.to_string();
738
+ match block_on(client.prepare(&sql)) {
739
+ Ok(stmt) => {
740
+ let mut g = STATEMENTS.write().unwrap();
741
+ let id = g.insert((*client_id as usize, stmt));
742
+ TishValue::Number(id as f64)
743
+ }
744
+ Err(e) => tish_err(format!("prepare: {}", crate::format_tish_pg_error(&e))),
745
+ }
746
+ }
747
+
748
+ /// `queryPrepared(client, stmt, params) -> rows`.
749
+ pub fn query_prepared(args: &[TishValue]) -> TishValue {
750
+ let Some(TishValue::Number(client_id)) = args.first() else {
751
+ return tish_err("queryPrepared: expected (client, stmt, params)");
752
+ };
753
+ let Some(TishValue::Number(stmt_id)) = args.get(1) else {
754
+ return tish_err("queryPrepared: expected (client, stmt, params)");
755
+ };
756
+ let params = match args.get(2) {
757
+ Some(TishValue::Array(a)) => a
758
+ .borrow()
759
+ .iter()
760
+ .map(tish_to_json)
761
+ .collect::<Vec<_>>(),
762
+ Some(TishValue::Null) | None => Vec::new(),
763
+ Some(v) => vec![tish_to_json(v)],
764
+ };
765
+ let Some(client) = get_client(*client_id) else {
766
+ return tish_err("queryPrepared: unknown client");
767
+ };
768
+ let Some((_cid, stmt)) = get_statement(*stmt_id) else {
769
+ return tish_err("queryPrepared: unknown statement");
770
+ };
771
+ // Fast path: build `Value::Object` rows directly from `Row` so we
772
+ // skip the `serde_json::Value` intermediate that `query_prepared`
773
+ // produces. Fewer allocations + interned column-name `Arc`s.
774
+ match block_on(client.query_prepared_to_values(&stmt, &params)) {
775
+ Ok(rows) => TishValue::Array(VmRef::new(rows)),
776
+ Err(e) => tish_err(format!("queryPrepared: {}", crate::format_tish_pg_error(&e))),
777
+ }
778
+ }
779
+
780
+ /// `queryAll(client, specs) -> array_of_row_arrays`.
781
+ /// `specs` is an Array of `[stmt_handle, params_array]` pairs. All are
782
+ /// polled concurrently on the same client to trigger tokio-postgres
783
+ /// automatic pipelining.
784
+ pub fn query_all(args: &[TishValue]) -> TishValue {
785
+ let Some(TishValue::Number(client_id)) = args.first() else {
786
+ return tish_err("queryAll: expected (client, specs[])");
787
+ };
788
+ let Some(TishValue::Array(specs)) = args.get(1) else {
789
+ return tish_err("queryAll: expected (client, specs[])");
790
+ };
791
+ let Some(client) = get_client(*client_id) else {
792
+ return tish_err("queryAll: unknown client");
793
+ };
794
+ let specs_vec: Vec<(Statement, Vec<JsonValue>)> = {
795
+ let borrow = specs.borrow();
796
+ let mut out = Vec::with_capacity(borrow.len());
797
+ for item in borrow.iter() {
798
+ let TishValue::Array(pair) = item else {
799
+ return tish_err("queryAll: each spec must be [stmt, params]");
800
+ };
801
+ let pair_b = pair.borrow();
802
+ let Some(TishValue::Number(stmt_id)) = pair_b.first() else {
803
+ return tish_err("queryAll: spec[0] must be a statement handle");
804
+ };
805
+ let Some((_cid, stmt)) = get_statement(*stmt_id) else {
806
+ return tish_err("queryAll: unknown statement");
807
+ };
808
+ let params = match pair_b.get(1) {
809
+ Some(TishValue::Array(a)) => {
810
+ a.borrow().iter().map(tish_to_json).collect::<Vec<_>>()
811
+ }
812
+ Some(TishValue::Null) | None => Vec::new(),
813
+ Some(v) => vec![tish_to_json(v)],
814
+ };
815
+ out.push((stmt, params));
816
+ }
817
+ out
818
+ };
819
+ // Fast path: same direct `Row -> Value::Object` mapping as
820
+ // `query_prepared` above, but pipelined across all specs.
821
+ match block_on(client.query_batch_to_values(specs_vec)) {
822
+ Ok(results) => {
823
+ let outer: Vec<TishValue> = results
824
+ .into_iter()
825
+ .map(|rows| TishValue::Array(VmRef::new(rows)))
826
+ .collect();
827
+ TishValue::Array(VmRef::new(outer))
828
+ }
829
+ Err(e) => tish_err(format!("queryAll: {}", crate::format_tish_pg_error(&e))),
830
+ }
831
+ }
832
+
833
+ /// `close(client_handle) -> null`.
834
+ pub fn close(args: &[TishValue]) -> TishValue {
835
+ if let Some(TishValue::Number(id)) = args.first() {
836
+ let mut g = CLIENTS.write().unwrap();
837
+ if g.contains(*id as usize) {
838
+ g.remove(*id as usize);
839
+ }
840
+ }
841
+ TishValue::Null
842
+ }
843
+
844
+ /// `migrate(client_handle, dir) -> { ok, applied: [name, …], error? }`.
845
+ ///
846
+ /// Reads `dir` for files matching `^\d+_.+\.sql$`, sorted lexically
847
+ /// (so `001_init.sql` < `002_users.sql` < …), creates a
848
+ /// `_tish_pg_migrations(name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ
849
+ /// NOT NULL DEFAULT NOW())` ledger, and applies each new file in a
850
+ /// single transaction per file. Idempotent — files already recorded
851
+ /// in the ledger are skipped.
852
+ pub fn migrate(args: &[TishValue]) -> TishValue {
853
+ use std::sync::Arc;
854
+ use tishlang_runtime::ObjectMap;
855
+
856
+ let Some(TishValue::Number(client_id)) = args.first() else {
857
+ return tish_err("migrate: expected (client_handle, dir)");
858
+ };
859
+ let Some(TishValue::String(dir)) = args.get(1) else {
860
+ return tish_err("migrate: expected (client_handle, dir)");
861
+ };
862
+ let Some(client) = get_client(*client_id) else {
863
+ return tish_err("migrate: unknown client handle");
864
+ };
865
+
866
+ let dir = std::path::PathBuf::from(dir.as_ref());
867
+ let entries = match std::fs::read_dir(&dir) {
868
+ Ok(e) => e,
869
+ Err(e) => return tish_err(format!("migrate: read_dir({:?}): {e}", dir)),
870
+ };
871
+
872
+ let mut files: Vec<(String, std::path::PathBuf)> = Vec::new();
873
+ for ent in entries.flatten() {
874
+ let name = ent.file_name().to_string_lossy().to_string();
875
+ if !name.ends_with(".sql") {
876
+ continue;
877
+ }
878
+ // accept any file ending in .sql; sort lexically.
879
+ files.push((name, ent.path()));
880
+ }
881
+ files.sort_by(|a, b| a.0.cmp(&b.0));
882
+
883
+ let raw_client = client.client();
884
+ let applied: Vec<TishValue> = match block_on(async {
885
+ // Create ledger
886
+ raw_client
887
+ .batch_execute(
888
+ "CREATE TABLE IF NOT EXISTS _tish_pg_migrations (\
889
+ name TEXT PRIMARY KEY, \
890
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())",
891
+ )
892
+ .await?;
893
+
894
+ // Read already-applied
895
+ let rows = raw_client
896
+ .query("SELECT name FROM _tish_pg_migrations", &[])
897
+ .await?;
898
+ let already: std::collections::HashSet<String> = rows
899
+ .iter()
900
+ .map(|r| r.get::<_, String>(0))
901
+ .collect();
902
+
903
+ let mut applied_now: Vec<String> = Vec::new();
904
+ for (name, path) in &files {
905
+ if already.contains(name) {
906
+ continue;
907
+ }
908
+ let sql = match std::fs::read_to_string(path) {
909
+ Ok(s) => s,
910
+ Err(e) => {
911
+ return Err::<_, TishPgError>(TishPgError::BadParam(format!(
912
+ "migrate: read {name}: {e}"
913
+ )))
914
+ }
915
+ };
916
+ // Each migration runs in its own transaction.
917
+ raw_client.batch_execute("BEGIN").await?;
918
+ let res = raw_client.batch_execute(&sql).await;
919
+ match res {
920
+ Ok(()) => {
921
+ raw_client
922
+ .execute(
923
+ "INSERT INTO _tish_pg_migrations(name) VALUES ($1)",
924
+ &[&name],
925
+ )
926
+ .await?;
927
+ raw_client.batch_execute("COMMIT").await?;
928
+ applied_now.push(name.clone());
929
+ }
930
+ Err(e) => {
931
+ let _ = raw_client.batch_execute("ROLLBACK").await;
932
+ return Err(TishPgError::Postgres(e));
933
+ }
934
+ }
935
+ }
936
+ Ok::<_, TishPgError>(applied_now)
937
+ }) {
938
+ Ok(v) => v.into_iter().map(|s| TishValue::String(s.into())).collect(),
939
+ Err(e) => return tish_err(format!("migrate: {}", crate::format_tish_pg_error(&e))),
940
+ };
941
+
942
+ let mut om = ObjectMap::with_capacity(2);
943
+ om.insert(Arc::from("ok"), TishValue::Bool(true));
944
+ om.insert(Arc::from("applied"), TishValue::Array(VmRef::new(applied)));
945
+ TishValue::Object(VmRef::new(om))
946
+ }
947
+ }
948
+
949
+ // Re-export the sync facade at crate root (pub fn(args: &[Value]) -> Value
950
+ // shape that `tishlang-cargo-bindgen` picks up automatically). The public
951
+ // names are snake_case because Tish's codegen snake-cases .tish imports on
952
+ // the Rust side (camelCase `queryAll` in .tish -> snake `query_all` here).
953
+ #[cfg(feature = "tish-bindings")]
954
+ pub use tish_sync::{
955
+ close, connect, migrate, per_worker_client, prepare, query_all, query_prepared,
956
+ };
957
+
958
+ #[cfg(test)]
959
+ mod tests {
960
+ use super::*;
961
+
962
+ #[tokio::test]
963
+ async fn parses_url() {
964
+ let cfg = parse_connection_string("postgres://u:p@h:5432/db").unwrap();
965
+ assert_eq!(cfg.get_user(), Some("u"));
966
+ }
967
+ }
@@ -35,7 +35,7 @@
35
35
  //! The Tish handler returns a synchronous `Value`. We call it from inside
36
36
  //! a tokio task via `tokio::task::spawn_blocking`, which:
37
37
  //! * detaches onto tokio's blocking thread pool,
38
- //! * lets `tish-pg`'s `block_on` detect no ambient runtime and block
38
+ //! * lets `tishlang_pg`'s `block_on` detect no ambient runtime and block
39
39
  //! directly (no extra thread spawn per query),
40
40
  //! * unblocks hyper's reactor to serve other connections while the VM
41
41
  //! runs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Tish - minimal TS/JS-compatible language. Run, REPL, build to native or other targets.",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file