foundry-rs 0.6.22

Configuration-driven REST backend library for Rust with PostgreSQL — define schemas, tables, and APIs in JSON, get a production-grade REST service.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Raw config types matching the JSON schema (postgres-config-schema + api_entities).

use serde::{Deserialize, Deserializer, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SchemaConfig {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub comment: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EnumConfig {
    pub id: String,
    #[serde(default)]
    pub schema_id: Option<String>,
    pub name: String,
    pub values: Vec<String>,
    #[serde(default)]
    pub comment: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TableCheck {
    pub name: String,
    pub expression: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PrimaryKeyConfig {
    Single(String),
    Composite(Vec<String>),
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TableConfig {
    pub id: String,
    #[serde(default)]
    pub schema_id: Option<String>,
    pub name: String,
    #[serde(default)]
    pub comment: Option<String>,
    pub primary_key: PrimaryKeyConfig,
    #[serde(default)]
    pub unique: Vec<Vec<String>>,
    #[serde(default)]
    pub check: Vec<TableCheck>,
    /// When true, a companion `{table}_audit` table is created and every
    /// create/update/delete/archive/unarchive is recorded there with the full row snapshot,
    /// action type, timestamp, and actor.
    #[serde(default)]
    pub audit_log: bool,
    /// Row-level versioning: when enabled, a `{table}_history` table is created and a snapshot
    /// of the row is written there before every UPDATE and DELETE.
    #[serde(default)]
    pub versioning: Option<VersioningConfig>,
    /// When true, this table holds data shared across all RLS tenants instead of being
    /// tenant-isolated. Under the RLS strategy it gets asymmetric row-level-security policies:
    /// every tenant may read all rows, but only the Platform Admin tenant
    /// (see `tenant::platform_tenant_id`) may insert/update/delete. Has no effect under the
    /// Database strategy (tenants are physically separate databases). Default false.
    #[serde(default)]
    pub global: bool,
}

/// Configuration for row-level versioning on a table.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VersioningConfig {
    pub enabled: bool,
    /// Maximum number of historical versions to retain per row (None = keep all).
    /// Must be ≥ 1 when set.
    #[serde(default)]
    pub keep_versions: Option<i64>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ColumnTypeConfig {
    Simple(String),
    Parameterized {
        name: String,
        params: Option<Vec<u32>>,
    },
}

#[derive(Clone, Debug, Serialize)]
pub enum ColumnDefaultConfig {
    Literal(String),
    Expression { expression: String },
}

impl<'de> Deserialize<'de> for ColumnDefaultConfig {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let v = serde_json::Value::deserialize(deserializer)?;
        match v {
            serde_json::Value::String(s) => Ok(ColumnDefaultConfig::Literal(s)),
            serde_json::Value::Object(mut obj) => {
                if let Some(serde_json::Value::String(s)) = obj.remove("expression") {
                    return Ok(ColumnDefaultConfig::Expression { expression: s });
                }
                if let Some(serde_json::Value::String(s)) = obj.remove("value").or_else(|| obj.remove("literal")) {
                    return Ok(ColumnDefaultConfig::Literal(s));
                }
                Err(serde::de::Error::custom(format!(
                    "column default must be a string, {{ \"expression\": \"...\" }}, or {{ \"value\": \"...\" }}; got object with keys: {:?}",
                    obj.keys().collect::<Vec<_>>()
                )))
            }
            serde_json::Value::Bool(b) => Ok(ColumnDefaultConfig::Literal(b.to_string())),
            serde_json::Value::Number(n) => Ok(ColumnDefaultConfig::Literal(n.to_string())),
            other => Err(serde::de::Error::custom(format!(
                "column default must be a string, boolean, number, or {{ \"expression\": \"...\" }}; got {}",
                type_name_of_json(&other)
            ))),
        }
    }
}

fn type_name_of_json(v: &serde_json::Value) -> &'static str {
    match v {
        serde_json::Value::Null => "null",
        serde_json::Value::Bool(_) => "boolean",
        serde_json::Value::Number(_) => "number",
        serde_json::Value::String(_) => "string",
        serde_json::Value::Array(_) => "array",
        serde_json::Value::Object(_) => "object",
    }
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ColumnConfig {
    pub id: String,
    pub table_id: String,
    pub name: String,
    #[serde(rename = "type")]
    pub type_: ColumnTypeConfig,
    #[serde(default = "default_true")]
    pub nullable: bool,
    #[serde(default)]
    pub default: Option<ColumnDefaultConfig>,
    #[serde(default)]
    pub comment: Option<String>,
    #[serde(default)]
    pub asset: Option<AssetColumnConfig>,
    /// When true, this JSON/JSONB column is an extensible "extensible fields" bag: per-tenant
    /// field definitions are stored in the KV registry and its keys become RSQL
    /// filterable/sortable via the `<column>.<key>` dotted syntax. Ignored (with a warning)
    /// for non-JSON columns.
    #[serde(default)]
    pub extensible: bool,
}

fn default_true() -> bool {
    true
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum IndexColumnEntry {
    Name(String),
    Spec {
        name: String,
        direction: Option<String>,
        nulls: Option<String>,
    },
    Expression {
        expression: String,
    },
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct IndexConfig {
    pub id: String,
    #[serde(default)]
    pub schema_id: Option<String>,
    pub table_id: String,
    pub name: String,
    #[serde(default)]
    pub method: Option<String>,
    #[serde(default)]
    pub unique: bool,
    pub columns: Vec<IndexColumnEntry>,
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default, rename = "where")]
    pub where_: Option<String>,
    #[serde(default)]
    pub comment: Option<String>,
}

impl IndexConfig {
    pub fn where_clause(&self) -> Option<&str> {
        self.where_.as_deref()
    }
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelationshipConfig {
    pub id: String,
    /// Defaults to the owning package's schema when absent.
    #[serde(default)]
    pub from_schema_id: Option<String>,
    pub from_table_id: String,
    pub from_column_id: String,
    /// When set, this relationship crosses into another installed package.
    /// The `to_schema_id` and `to_table_id` are resolved from that package's config.
    #[serde(default)]
    pub to_package_id: Option<String>,
    /// Defaults to the owning package's schema when absent (or to the target package's schema
    /// for cross-package relationships).
    #[serde(default)]
    pub to_schema_id: Option<String>,
    pub to_table_id: String,
    pub to_column_id: String,
    #[serde(default)]
    pub on_update: Option<String>,
    #[serde(default)]
    pub on_delete: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ValidationRule {
    #[serde(default)]
    pub required: Option<bool>,
    #[serde(default)]
    pub format: Option<String>,
    #[serde(default)]
    pub max_length: Option<u32>,
    #[serde(default)]
    pub min_length: Option<u32>,
    #[serde(default)]
    pub pattern: Option<String>,
    #[serde(default)]
    pub allowed: Option<Vec<serde_json::Value>>,
    #[serde(default)]
    pub minimum: Option<f64>,
    #[serde(default)]
    pub maximum: Option<f64>,
    // Asset-specific validation (only applied when the column type is "asset")
    #[serde(default)]
    pub allowed_mime_types: Option<Vec<String>>,
    #[serde(default)]
    pub allowed_extensions: Option<Vec<String>>,
    #[serde(default)]
    pub max_size_mb: Option<f64>,
    #[serde(default)]
    pub min_size_kb: Option<f64>,
    #[serde(default)]
    pub max_filename_length: Option<u32>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AssetColumnConfig {
    /// Path prefix template. Supports {yyyy}, {mm}, {dd}, {hh}, {tenant_id}, {entity}.
    #[serde(default)]
    pub prefix: Option<String>,
    /// Byte-level compression before upload: "none" | "gzip" | "zstd". Default: "none".
    #[serde(default)]
    pub compression: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventCondition {
    /// Column name (snake_case) to inspect on the saved row.
    pub field: String,
    /// Fire when the field's new value equals this (post-update check).
    #[serde(default)]
    pub changed_to: Option<serde_json::Value>,
    /// Fire when the field's current value equals this.
    #[serde(default)]
    pub equals: Option<serde_json::Value>,
    /// true = fire when field is non-null; false = fire when null.
    #[serde(default)]
    pub not_null: Option<bool>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EntityEventTrigger {
    pub id: String,
    /// Lifecycle hook: "create" | "update" | "delete" | "archive".
    pub on: String,
    /// Suffix of the event type sent to decision-hub.
    /// Defaults to "created" / "updated" / "deleted" / "archived" when omitted.
    #[serde(default)]
    pub event_name: Option<String>,
    /// Only fire when this condition is satisfied against the saved row (snake_case keys).
    #[serde(default)]
    pub condition: Option<EventCondition>,
    /// Related entities to expand into `context.entity`, using the same names as `?include=`.
    /// Empty (the default) publishes the flat row. Expansion is a separate SELECT run inside the
    /// detached publish task, so it never adds latency to the originating request.
    #[serde(default)]
    pub include: Vec<String>,
}

/// Configuration for exposing a selected API entity as an MCP tool.
/// Only takes effect when the `mcp` feature is enabled.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct McpEntityConfig {
    /// Opt-in to MCP exposure. Default false.
    #[serde(default)]
    pub enabled: bool,
    /// Subset of the entity's REST operations to expose as MCP tools.
    /// Defaults to all operations on the entity when omitted.
    /// Valid values: "list", "read", "create", "update", "delete".
    #[serde(default)]
    pub operations: Vec<String>,
    /// Prefix for generated tool names. Defaults to `path_segment`.
    #[serde(default)]
    pub tool_prefix: Option<String>,
    /// Human-readable description injected into each tool's MCP description.
    #[serde(default)]
    pub description: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiEntityConfig {
    pub entity_id: String,
    pub path_segment: String,
    pub operations: Vec<String>,
    /// Column names that must never be exposed in API responses (e.g. password hashes, secrets).
    #[serde(default)]
    pub sensitive_columns: Vec<String>,
    #[serde(default)]
    pub validation: std::collections::HashMap<String, ValidationRule>,
    /// Column whose null→non-null transition signals an archive. Required for on:"archive" triggers.
    #[serde(default)]
    pub archive_field: Option<String>,
    /// Decision-hub event triggers for this entity.
    #[serde(default)]
    pub events: Vec<EntityEventTrigger>,
    /// Column holding the human-readable natural key used to resolve `parentRef` during bulk
    /// create (e.g. `"location_id"` for locations, `"product_id"` for products). When set, bulk
    /// create accepts a virtual `parentRef` field; the SDK resolves it to a UUID and writes
    /// `parent_id` in a second pass after all rows are inserted.
    #[serde(default)]
    pub parent_ref_column: Option<String>,
    /// MCP tool exposure config. Only effective when the `mcp` feature is enabled.
    #[serde(default)]
    pub mcp: Option<McpEntityConfig>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KvStoreConfig {
    pub id: String,
    pub namespace: String,
    #[serde(default)]
    pub comment: Option<String>,
}

/// A single named parameter of a report. Reuses the entity [`ValidationRule`] engine (flattened
/// into this struct) so `required`/`allowed`/`minimum`/`pattern`/`format`/etc. all apply to the
/// value the caller supplies at run time. `default` is applied when the param is absent.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReportParam {
    pub name: String,
    /// Value used when the caller omits this param. Ignored when the param is required.
    #[serde(default)]
    pub default: Option<serde_json::Value>,
    /// Optional SQL cast applied to the bound value (e.g. "timestamptz", "int", "uuid").
    /// All params bind as TEXT, so numeric/temporal params need a cast to compare correctly.
    #[serde(default)]
    pub db_type: Option<String>,
    #[serde(flatten)]
    pub rule: ValidationRule,
}

/// A read-only reporting query. The SQL is trusted (authored at deploy time, admin-gated on
/// registration); only the declared params are runtime input. Named params (`:from`, `:to`) are
/// translated to positional placeholders when the model is resolved. Reports carry no DDL — they
/// are pure metadata and can be added/updated/removed at runtime without touching the schema.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReportConfig {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    /// SQL schemas the query references (used for read-only role grants and a fast pre-check).
    #[serde(default)]
    pub schemas: Vec<String>,
    /// Parameterized SQL using named params, e.g. `SELECT ... WHERE created_at >= :from`.
    pub sql: String,
    #[serde(default)]
    pub params: Vec<ReportParam>,
    /// When true (the default), the SQL is validated with `EXPLAIN` at registration time so
    /// missing tables/columns fail fast rather than at first run. Set false for reports that
    /// reference packages installed later (lazy validation at run time).
    #[serde(default)]
    pub validate_on_register: Option<bool>,
    /// Per-report result-cache TTL in seconds. Only used when result caching is enabled globally
    /// (env `ARCHITECT_REPORT_CACHE`). Falls back to `ARCHITECT_REPORT_CACHE_TTL_SECS` when unset;
    /// a value of 0 disables caching for this report even when the global flag is on.
    #[serde(default)]
    pub cache_ttl_secs: Option<i64>,
}

/// All config types in one struct for in-memory loading.
#[derive(Clone, Debug, Default)]
pub struct FullConfig {
    pub schemas: Vec<SchemaConfig>,
    pub enums: Vec<EnumConfig>,
    pub tables: Vec<TableConfig>,
    pub columns: Vec<ColumnConfig>,
    pub indexes: Vec<IndexConfig>,
    pub relationships: Vec<RelationshipConfig>,
    pub api_entities: Vec<ApiEntityConfig>,
    pub kv_stores: Vec<KvStoreConfig>,
    pub reports: Vec<ReportConfig>,
}