Skip to main content

systemprompt_extension/
error.rs

1//! Typed error enums raised by extension registration and configuration.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum LoaderError {
10    #[error("Extension '{extension}' requires dependency '{dependency}' which is not registered")]
11    MissingDependency {
12        extension: String,
13        dependency: String,
14    },
15
16    #[error("Extension with ID '{0}' is already registered")]
17    DuplicateExtension(String),
18
19    #[error("Extension '{0}' is required and cannot be disabled")]
20    RequiredExtensionDisabled(String),
21
22    #[error(
23        "Extension '{dependency}' is disabled but extension '{extension}' depends on it; disable \
24         '{extension}' as well or re-enable '{dependency}'"
25    )]
26    DisabledDependency {
27        extension: String,
28        dependency: String,
29    },
30
31    #[error("Failed to initialize extension '{extension}': {message}")]
32    InitializationFailed { extension: String, message: String },
33
34    #[error("Failed to install schema for extension '{extension}': {message}")]
35    SchemaInstallationFailed { extension: String, message: String },
36
37    #[error("Migration failed for extension '{extension}': {message}")]
38    MigrationFailed { extension: String, message: String },
39
40    #[error(
41        "Migration {version} for extension '{extension}' is not reversible (no down SQL provided)"
42    )]
43    MigrationNotReversible { extension: String, version: u32 },
44
45    #[error(
46        "Extension '{extension}' migration slot {version} was applied as '{stored_name}' but the \
47         tree now names it '{current_name}'. A migration file was deleted and its number reused; \
48         established databases have already spent that slot, so the new SQL would be skipped, \
49         never executed. Renumber it above every used slot and leave a \
50         `{version:03}_{stored_name}.tombstone` behind so the number cannot be claimed again."
51    )]
52    MigrationSlotReused {
53        extension: String,
54        version: u32,
55        stored_name: String,
56        current_name: String,
57    },
58
59    #[error("Configuration validation failed for extension '{extension}': {message}")]
60    ConfigValidationFailed { extension: String, message: String },
61
62    #[error("Extension '{extension}' uses reserved API path '{path}'")]
63    ReservedPathCollision { extension: String, path: String },
64
65    #[error(
66        "Extension '{extension}' has invalid base path '{path}': must be / or start with /api/"
67    )]
68    InvalidBasePath { extension: String, path: String },
69
70    #[error("Dependency cycle detected while ordering extensions: {chain}")]
71    DependencyCycle { chain: String },
72
73    #[error(
74        "Extension '{extension}' migration ALTERs table '{table}' but does not create it in its \
75         schemas() nor declare it in cross_extension_tables(); cross-extension table mutations \
76         must be declared explicitly"
77    )]
78    CrossExtensionAlterUndeclared { extension: String, table: String },
79
80    #[error(
81        "Extension '{extension}' migration {migration} references {kind} '{object}' via {how}, \
82         which only a declarative schema file creates; the dependent phase runs after \
83         migrations, so a database that has not booted on that schema fails here. Create it in \
84         a migration, guard the reference in a DO $$ block that tests pg_trigger/pg_views, or \
85         leave it to the declarative schema"
86    )]
87    MigrationReferencesDeclarativeObject {
88        extension: String,
89        migration: String,
90        kind: String,
91        object: String,
92        how: String,
93    },
94
95    #[error(
96        "Extension '{extension}' migration {migration} toggles trigger '{trigger}' on '{table}' \
97         by name; that statement fails on any database where the trigger has since been retired. \
98         The runner already suspends every row trigger on the tables a migration writes, so \
99         delete the toggle, or guard it in a DO $$ block that tests pg_trigger first"
100    )]
101    MigrationTogglesTriggerByName {
102        extension: String,
103        migration: String,
104        table: String,
105        trigger: String,
106    },
107
108    #[error(
109        "Trigger '{trigger}' on '{table}' runs {function}, which uses '{relation}', and \
110         '{relation}' no longer exists: every write to '{table}' would fail. Retire the trigger in \
111         its extension's `retirements()` (it runs before any migration), or restore the relation"
112    )]
113    DanglingTriggerRoutine {
114        trigger: String,
115        table: String,
116        function: String,
117        relation: String,
118    },
119
120    #[error(
121        "Extension '{extension}' migration {version} ('{name}') has been edited since it was \
122         applied (stored checksum {stored_checksum}, current {current_checksum}). Refusing to \
123         proceed. If the database schema already matches the edited file, run `systemprompt \
124         infra db migrate-repair --reconcile-only --apply` to rewrite the stored checksum \
125         without executing any SQL. To re-execute the edited migration, run `systemprompt infra \
126         db migrate-repair --apply`. Passing --allow-checksum-drift bypasses the check without \
127         fixing it."
128    )]
129    MigrationChecksumDrift {
130        extension: String,
131        version: u32,
132        name: String,
133        stored_checksum: String,
134        current_checksum: String,
135    },
136
137    #[error(
138        "Table '{table}' is created by both extension '{extension_a}' and '{extension_b}'; every \
139         table must be declared by exactly one extension"
140    )]
141    DuplicateTableOwner {
142        table: String,
143        extension_a: String,
144        extension_b: String,
145    },
146
147    #[error(
148        "Extension '{extension}' declares cross_extension_tables() entry '{table}', which is not \
149         a table created by any other loaded extension"
150    )]
151    CrossExtensionTableNotOwned { extension: String, table: String },
152
153    #[error(
154        "Extension '{extension}' seed '{seed}' contains forbidden statement '{statement}'; seeds \
155         may only contain INSERT … ON CONFLICT, UPDATE, MERGE, or WITH … INSERT"
156    )]
157    InvalidSeedStatement {
158        extension: String,
159        seed: String,
160        statement: String,
161    },
162
163    #[error(
164        "Extension '{extension}' seed '{seed}' contains a bare INSERT with no ON CONFLICT clause; \
165         seeds run on every boot and must be idempotent — add ON CONFLICT … DO NOTHING/UPDATE"
166    )]
167    SeedInsertNotIdempotent { extension: String, seed: String },
168
169    #[error("Extension '{extension}' seed '{seed}' failed to parse or apply: {message}")]
170    SeedFailed {
171        extension: String,
172        seed: String,
173        message: String,
174    },
175}
176
177#[derive(Debug, Error)]
178pub enum ExtensionConfigError {
179    #[error("Configuration key '{0}' not found")]
180    NotFound(String),
181
182    #[error("Invalid configuration value for '{key}': {message}")]
183    InvalidValue { key: String, message: String },
184
185    #[error("Failed to parse configuration: {message}")]
186    ParseError { message: String },
187
188    #[error("Schema validation failed: {0}")]
189    SchemaValidation(String),
190}