Skip to content

Commit 1b2f971

Browse files
committed
Normalize Dioxus signal API and improve SQL migration parsing
1 parent 12e7a05 commit 1b2f971

53 files changed

Lines changed: 469 additions & 282 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/forge-codegen/src/dioxus/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ pub use api::*;
4545
pub use forge_dioxus::{
4646
ConnectionState, ForgeAuth, ForgeAuthProvider, ForgeClient, ForgeClientConfig,
4747
ForgeClientError, ForgeError, ForgeProvider, ForgeUpload, JobExecutionState, Mutation,
48-
QueryState, SubscriptionHandle, SubscriptionState, TokenPair, WorkflowExecutionState,
48+
QueryState, SignalError, SubscriptionHandle, SubscriptionState, TokenPair, WorkflowExecutionState,
4949
use_auth_key, use_connection_state, use_forge_auth, use_forge_client, use_forge_job,
5050
use_forge_mutation, use_forge_query, use_forge_subscription, use_forge_workflow,
51-
use_require_auth, use_viewer,
51+
use_viewer,
5252
};
5353
pub use types::*;
5454
"#

crates/forge-codegen/src/emit.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ fn ts_custom(name: &str, pos: Position) -> String {
6969
Position::Arg => "Uint8Array".into(),
7070
},
7171

72+
"Uuid" | "uuid::Uuid" | "DateTime<Utc>" | "NaiveDate" | "NaiveDateTime" | "Instant"
73+
| "LocalDate" | "LocalTime" | "Timestamp" => "string".into(),
74+
75+
"i32" | "i64" | "u32" | "u64" | "f32" | "f64" | "usize" | "isize" => "number".into(),
76+
77+
"bool" => "boolean".into(),
78+
79+
"Value" | "serde_json::Value" => "unknown".into(),
80+
7281
// Unparsed generic types that leaked through as Custom.
7382
_ if name.starts_with("Vec<") => {
7483
let inner = name
@@ -90,7 +99,7 @@ fn ts_custom(name: &str, pos: Position) -> String {
9099
.and_then(|s| s.strip_suffix('>'))
91100
.unwrap_or("unknown");
92101
format!(
93-
"{{ items: {}[], page_info: PageInfo }}",
102+
"Page<{}>",
94103
ts_type(&RustType::Custom(inner.to_string()), pos)
95104
)
96105
}
@@ -152,6 +161,11 @@ fn dioxus_custom(name: &str) -> String {
152161
"Uuid" | "uuid::Uuid" => "String".into(),
153162
"DateTime<Utc>" | "NaiveDate" | "NaiveDateTime" | "Instant" | "LocalDate" | "LocalTime"
154163
| "Timestamp" => "String".into(),
164+
"i32" | "u32" | "usize" | "isize" => "i64".into(),
165+
"i64" | "u64" => "i64".into(),
166+
"f32" => "f32".into(),
167+
"f64" => "f64".into(),
168+
"bool" => "bool".into(),
155169
"Value" | "serde_json::Value" => "JsonValue".into(),
156170
"Bytes" => "Vec<u8>".into(),
157171
"Upload" => "ForgeUpload".into(),
@@ -376,7 +390,7 @@ mod tests {
376390
);
377391
assert_eq!(
378392
ts_type(&RustType::Custom("Page<User>".into()), Position::Arg),
379-
"{ items: User[], page_info: PageInfo }"
393+
"Page<User>"
380394
);
381395
assert_eq!(dioxus_type(&RustType::Custom("Cursor".into())), "String");
382396
assert_eq!(

crates/forge-codegen/src/typescript/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ export function toReactiveMutation<TArgs, TResult>(
7272
} catch (e) {
7373
const err =
7474
e instanceof ForgeClientError
75-
? { code: e.code, message: e.message, details: e.details }
76-
: { code: "UNKNOWN", message: String(e) };
75+
? e
76+
: new ForgeClientError("UNKNOWN", String(e));
7777
state.error = err;
7878
throw e;
7979
} finally {

crates/forge-codegen/src/typescript/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ pub fn generate(registry: &SchemaRegistry, referenced_types: &[String]) -> Resul
5656
output.push_str("}\n\n");
5757
}
5858

59+
output.push_str(
60+
"export type Cursor = string;\n\nexport interface PageInfo {\n has_next_page: boolean;\n end_cursor?: Cursor;\n total_count?: number;\n}\n\nexport interface Page<T> {\n items: T[];\n page_info: PageInfo;\n}\n\n",
61+
);
62+
5963
// Emit built-in types that are referenced by API bindings but not in the registry.
6064
for (name, definition) in BUILTIN_TYPES {
6165
if !defined_names.contains(*name) && referenced_types.iter().any(|t| t.as_str() == *name) {

crates/forge-macros/src/utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ pub fn is_primitive_arg_type(ty: &syn::Type) -> bool {
410410
| "BTreeMap"
411411
| "HashSet"
412412
| "BTreeSet"
413+
| "Uuid"
413414
)
414415
}
415416

crates/forge-runtime/src/migrations/runner.rs

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -476,48 +476,108 @@ fn split_sql_statements(sql: &str) -> Vec<String> {
476476
let mut current = String::new();
477477
let mut in_dollar_quote = false;
478478
let mut dollar_tag = String::new();
479+
let mut in_line_comment = false;
480+
let mut in_block_comment = false;
481+
let mut in_string_literal = false;
479482
let mut chars = sql.chars().peekable();
480483

481484
while let Some(c) = chars.next() {
482485
current.push(c);
483486

484-
// Check for dollar-quoting start/end
487+
if in_line_comment {
488+
if c == '\n' {
489+
in_line_comment = false;
490+
}
491+
continue;
492+
}
493+
494+
if in_block_comment {
495+
if c == '*' && chars.peek() == Some(&'/') {
496+
current.push(chars.next().expect("peeked char"));
497+
in_block_comment = false;
498+
}
499+
continue;
500+
}
501+
502+
if in_string_literal {
503+
if c == '\'' {
504+
if chars.peek() == Some(&'\'') {
505+
current.push(chars.next().expect("peeked char"));
506+
} else {
507+
in_string_literal = false;
508+
}
509+
}
510+
continue;
511+
}
512+
513+
if in_dollar_quote {
514+
if c == '$' {
515+
let mut potential_tag = String::from("$");
516+
while let Some(&next_c) = chars.peek() {
517+
if next_c == '$' {
518+
potential_tag.push(chars.next().expect("peeked char"));
519+
current.push('$');
520+
break;
521+
} else if next_c.is_alphanumeric() || next_c == '_' {
522+
let ch = chars.next().expect("peeked char");
523+
potential_tag.push(ch);
524+
current.push(ch);
525+
} else {
526+
break;
527+
}
528+
}
529+
if potential_tag.len() >= 2
530+
&& potential_tag.ends_with('$')
531+
&& potential_tag == dollar_tag
532+
{
533+
in_dollar_quote = false;
534+
dollar_tag.clear();
535+
}
536+
}
537+
continue;
538+
}
539+
540+
// Outside all quoted/comment contexts
541+
if c == '-' && chars.peek() == Some(&'-') {
542+
current.push(chars.next().expect("peeked char"));
543+
in_line_comment = true;
544+
continue;
545+
}
546+
547+
if c == '/' && chars.peek() == Some(&'*') {
548+
current.push(chars.next().expect("peeked char"));
549+
in_block_comment = true;
550+
continue;
551+
}
552+
553+
if c == '\'' {
554+
in_string_literal = true;
555+
continue;
556+
}
557+
485558
if c == '$' {
486-
// Look for a dollar-quote tag like $$ or $tag$
487559
let mut potential_tag = String::from("$");
488-
489-
// Collect characters until we hit another $ or non-identifier char
490560
while let Some(&next_c) = chars.peek() {
491561
if next_c == '$' {
492-
// Safe: peek confirmed the char exists
493562
potential_tag.push(chars.next().expect("peeked char"));
494563
current.push('$');
495564
break;
496565
} else if next_c.is_alphanumeric() || next_c == '_' {
497-
let c = chars.next().expect("peeked char");
498-
potential_tag.push(c);
499-
current.push(c);
566+
let ch = chars.next().expect("peeked char");
567+
potential_tag.push(ch);
568+
current.push(ch);
500569
} else {
501570
break;
502571
}
503572
}
504-
505-
// Check if this is a valid dollar-quote delimiter (ends with $)
506573
if potential_tag.len() >= 2 && potential_tag.ends_with('$') {
507-
if in_dollar_quote && potential_tag == dollar_tag {
508-
// End of dollar-quoted string
509-
in_dollar_quote = false;
510-
dollar_tag.clear();
511-
} else if !in_dollar_quote {
512-
// Start of dollar-quoted string
513-
in_dollar_quote = true;
514-
dollar_tag = potential_tag;
515-
}
574+
in_dollar_quote = true;
575+
dollar_tag = potential_tag;
516576
}
577+
continue;
517578
}
518579

519-
// Split on semicolon only if not inside a dollar-quoted string
520-
if c == ';' && !in_dollar_quote {
580+
if c == ';' {
521581
let stmt = current.trim().trim_end_matches(';').trim().to_string();
522582
if !stmt.is_empty() {
523583
statements.push(stmt);
@@ -526,7 +586,6 @@ fn split_sql_statements(sql: &str) -> Vec<String> {
526586
}
527587
}
528588

529-
// Don't forget the last statement (might not end with ;)
530589
let stmt = current.trim().trim_end_matches(';').trim().to_string();
531590
if !stmt.is_empty() {
532591
statements.push(stmt);

crates/forge/src/cli/frontend_codegen.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ fn generate_types(output_dir: &Path, force: bool) -> Result<()> {
8585
8686
// Common types (re-exported from @forge-rs/svelte for convenience)
8787
export type { ForgeError, QueryResult, SubscriptionResult } from "@forge-rs/svelte";
88+
89+
export type Cursor = string;
90+
91+
export interface PageInfo {
92+
has_next_page: boolean;
93+
end_cursor?: Cursor;
94+
total_count?: number;
95+
}
96+
97+
export interface Page<T> {
98+
items: T[];
99+
page_info: PageInfo;
100+
}
88101
"#;
89102

90103
fs::write(file_path, content)?;

docs/docs/connect/generated-client.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ Inside the provider tree, use these hooks:
140140
| `Vec<T>` | `T[]` |
141141
| `HashMap<K, V>` | `Record<string, V>` |
142142
| `Value` (JSON) | `unknown` |
143+
| `Page<T>` | `Page<T>` |
144+
| `Cursor` | `string` |
143145
| `Upload` | `File \| Blob` |
144146
| `Bytes` (return) | `Blob` |
145147
| `Bytes` (arg) | `Uint8Array` |
@@ -155,6 +157,7 @@ Inside the provider tree, use these hooks:
155157
| `bool` | `bool` |
156158
| `Option<T>` | `Option<T>` |
157159
| `Vec<T>` | `Vec<T>` |
160+
| `Page<T>` | `forge_core::Page<T>` |
158161
| `Upload` | `ForgeUpload` |
159162
| `serde_json::Value` | `JsonValue` |
160163
| `Bytes` | `Vec<u8>` |

docs/docs/reference/errors.mdx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Err(ForgeError::NotFound("User not found".to_string()))
4646
| `limit` | `u32` | Configured request limit |
4747
| `remaining` | `u32` | Tokens remaining (0 when exceeded) |
4848

49-
The HTTP response serializes only `retry_after_secs` in the `details` object. The `limit` and `remaining` fields are available in Rust but not included in the JSON response.
49+
The HTTP response serializes only top-level `retry_after_secs`. The Svelte client exposes it as `retryAfterSecs`; Dioxus keeps the Rust field name `retry_after_secs`. The `limit` and `remaining` fields are available in Rust but not included in the JSON response.
5050

5151
## HTTP Response Format
5252

@@ -86,7 +86,11 @@ Generated client includes error types:
8686
export interface ForgeError {
8787
code: string;
8888
message: string;
89+
retryAfterSecs?: number;
8990
details?: unknown;
91+
isRateLimited(): boolean;
92+
isUnauthorized(): boolean;
93+
isValidation(): boolean;
9094
}
9195
```
9296

@@ -309,8 +313,8 @@ Handle on the frontend:
309313
try {
310314
await expensiveQuery();
311315
} catch (e) {
312-
if (e instanceof ForgeClientError && e.code === 'RATE_LIMITED') {
313-
const retryAfter = e.details?.retry_after_secs ?? 60;
316+
if (e instanceof ForgeClientError && e.isRateLimited()) {
317+
const retryAfter = e.retryAfterSecs ?? 60;
314318
showMessage(`Rate limited. Try again in ${retryAfter} seconds`);
315319
}
316320
}
@@ -412,7 +416,7 @@ onAuthError: (error) => {
412416

413417
```typescript
414418
if (error.code === 'RATE_LIMITED') {
415-
await sleep(error.details.retry_after_secs * 1000);
419+
await sleep((error.retryAfterSecs ?? 1) * 1000);
416420
return retry();
417421
}
418422
```

docs/docs/ship/signals.mdx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ signals.captureError(new Error('Something broke'), { component: 'Cart' });
2222
// Dioxus — accessed anywhere inside ForgeProvider
2323
let signals = use_signals();
2424

25-
signals.track("button_clicked", json!({"button_id": "signup"}));
25+
signals.track_with_properties("button_clicked", json!({"button_id": "signup"}));
2626
signals.identify("user-uuid", json!({"plan": "pro"})).await;
2727
signals.breadcrumb("Added item to cart", Some(json!({"item_id": "123"})));
28-
signals.capture_error("Something broke", json!({"component": "Cart"})).await;
28+
signals.capture_error("Something broke", Some(json!({"component": "Cart"})));
2929
```
3030

3131
## What Happens
@@ -34,7 +34,7 @@ Forge captures analytics at two levels that get correlated automatically.
3434

3535
**Server-side auto-capture**: The function executor records every RPC call with its name, kind (query/mutation), duration, success/failure status, and the caller's identity. Jobs, crons, workflows, webhooks, and daemon runs emit `server_execution` events with the same shape. Auth failures and rate-limit rejections emit `track` events (`auth.failed`, `rate_limit.exceeded`) so dashboards surface attack patterns. All of this happens inside the framework, so your handler code stays clean.
3636

37-
**Client-side tracking**: The `ForgeSignals` class (Svelte) or `use_signals()` hook (Dioxus) runs in the browser and captures page views on SPA navigation, frontend errors (window.onerror, unhandled rejections), Web Vitals (LCP, CLS, INP, FCP, TTFB, navigation timing, long tasks), network online/offline transitions, and any custom events you send with `track()`. Events are batched locally, persisted to `localStorage` so they survive page reloads, and flushed to the server periodically, when the batch fills up, on `visibilitychange`/`pagehide`, or when the network returns after being offline.
37+
**Client-side tracking**: The `ForgeSignals` class (Svelte) runs in the browser. The `use_signals()` hook (Dioxus) works across web, desktop, and mobile; browser-only auto-capture such as Web Vitals and `window.onerror` is enabled only on `wasm32`. Custom events and error reports work on every Dioxus target. Events are batched locally, persisted where platform storage is available, and flushed to the server periodically or when the batch fills up.
3838

3939
**Correlation**: Every client-initiated RPC call includes an `x-correlation-id` header. This ID links the frontend event (user clicked a button) to the backend execution (the mutation that ran). Error reports include the last correlation ID and a trail of breadcrumbs for reproduction.
4040

@@ -130,6 +130,11 @@ Record a custom event with arbitrary properties.
130130
signals.track('subscription_upgraded', { from: 'free', to: 'pro' });
131131
```
132132

133+
```rust
134+
signals.track("subscription_upgraded");
135+
signals.track_with_properties("subscription_upgraded", json!({"from": "free", "to": "pro"}));
136+
```
137+
133138
### identify(userId, traits)
134139

135140
Link the current anonymous session to a known user. Call this after login. Traits are stored as JSONB in the `forge_signals_users` table.
@@ -154,6 +159,10 @@ Report a frontend error with optional context. Auto-captured errors go through t
154159
signals.captureError(new Error('Payment failed'), { orderId: '123' });
155160
```
156161

162+
```rust
163+
signals.capture_error("Payment failed", Some(json!({"order_id": "123"})));
164+
```
165+
157166
### page()
158167

159168
Manually record a page view. Usually not needed since auto page views track SPA navigation.

0 commit comments

Comments
 (0)