feat: release Dever 0.1.5 with direct workers and long transactions

This commit is contained in:
Codex committed 2026-10-11 11:17:33 +00:00
1 parent 89629c2197
commit a900d7e8db
153 files changed
+3676 -3708

No files matched your search

+35 -44
View File
@@ -99,11 +99,13 @@ pub(super) fn validate_rest_bindings(program: &Program, errors: &mut Vec<Diagnos
ExpressionKind::Call {
target: CallTarget::Function(target),
..
} => {
if !rest_function_allowed(program, *target, &model_domain) {
errors.push(Diagnostic::error("C013", "REST field bindings call only pure same-domain Domain functions or pure standard library functions", value.span));
}
} if !rest_function_allowed(program, *target, &model_domain) => {
errors.push(Diagnostic::error("C013", "REST field bindings call only pure same-domain Domain functions or pure standard library functions", value.span));
}
ExpressionKind::Call {
target: CallTarget::Function(_),
..
} => {}
ExpressionKind::Call { .. }
| ExpressionKind::ModelOperation { .. }
| ExpressionKind::JobEnqueue { .. }
@@ -270,57 +272,44 @@ fn rest_intrinsic_allowed(operation: crate::intrinsic::Intrinsic) -> bool {
}
pub(super) fn validate_write_transactions(program: &Program, errors: &mut Vec<Diagnostic>) {
let write_routes = program
let roots = program
.api_routes
.iter()
.filter(|route| route.method != "GET")
.map(|route| route.function)
.map(|route| (route.function, route.long))
.chain(
program
.api_commands
.iter()
.map(|command| (command.function, command.long)),
)
.chain(program.jobs.iter().map(|job| (job.function, job.long)))
.collect::<std::collections::BTreeSet<_>>();
for function in &write_routes {
let root = crate::specialize::Specialization {
function: *function,
handlers: Vec::new(),
};
let password_crypto = crate::specialize::uses_intrinsic(
program,
&root,
crate::intrinsic::Intrinsic::CryptoPasswordVerify,
) || crate::specialize::uses_intrinsic(
program,
&root,
crate::intrinsic::Intrinsic::CryptoPasswordHash,
);
if password_crypto
&& let Some(span) = crate::specialize::write_outside_transaction(program, &root)
{
let mut error = Diagnostic::error(
"C014",
"password crypto API writes must be enclosed in an explicit transaction function",
program.functions[*function].span,
);
error.related.push(crate::diagnostic::Label {
span,
message: "database write occurs outside the short transaction boundary".into(),
});
errors.push(error);
}
}
let roots = write_routes
.into_iter()
.chain(program.api_commands.iter().map(|command| command.function))
.chain(program.jobs.iter().map(|job| job.function))
.collect::<std::collections::BTreeSet<_>>();
for function in roots {
for (function, long) in roots {
let root = crate::specialize::Specialization {
function,
handlers: Vec::new(),
};
if crate::specialize::writes_database(program, &root)
if long {
if let Some(span) = crate::specialize::write_outside_transaction(program, &root) {
let mut error = Diagnostic::error(
"C014",
"long entry database writes must be enclosed in an explicit transaction function",
program.functions[function].span,
);
error.related.push(crate::diagnostic::Label {
span,
message: "database write occurs outside the explicit transaction boundary"
.into(),
});
errors.push(error);
}
} else if crate::specialize::writes_database(program, &root)
&& crate::specialize::starts_transaction_unsafe_work(program, &root)
{
errors.push(Diagnostic::error(
"C014",
"an API, CMD, or Job database transaction cannot start concurrent work",
"automatic entry transactions cannot contain external I/O, Workers, waits, concurrency, or password crypto; declare the entry 'long' and enclose its writes in explicit transaction functions",
program.functions[function].span,
));
}
@@ -526,6 +515,7 @@ pub(super) fn register(
let path = format!("/{prefix}/{}", binding.action.text);
if occupy(&mut routes, (method, path.clone()), binding.span, "HTTP route", errors) {
entries.routes.push(ApiRoute { method, path, function: id, inputs,
long: binding.long,
directory: directory.clone(), anonymous: binding.anonymous,
component: component.clone(), domain: domain.clone(),
action: binding.action.text.clone(), auth: None, permission: None,
@@ -536,7 +526,7 @@ pub(super) fn register(
} else {
let name = format!("{domain_name}.{}", binding.action.text);
if occupy(&mut commands, name.clone(), binding.span, "command", errors) {
entries.commands.push(ApiCommand { name, function: id, inputs, components: Vec::new() });
entries.commands.push(ApiCommand { name, function: id, long: binding.long, inputs, components: Vec::new() });
}
}
}
@@ -981,6 +971,7 @@ fn legacy_routes(
inputs,
directory: Vec::new(),
anonymous: false,
long: false,
span: clause.span,
component: function.name.split('.').next().unwrap_or("app").to_owned(),
domain: function.name.split('.').nth(1).unwrap_or("main").to_owned(),
+14 -11
View File
@@ -113,18 +113,19 @@ pub(super) fn register(
let mut scheduled = BTreeSet::new();
for declaration in &package.declarations {
match declaration {
Declaration::Database(binding) if is_job => {
if connection
.replace(ConnectionSelector::Explicit(binding.name.text.clone()))
.is_some()
{
errors.push(Diagnostic::error(
"C002",
"duplicate Job database binding",
binding.span,
));
}
Declaration::Database(binding)
if is_job
&& connection
.replace(ConnectionSelector::Explicit(binding.name.text.clone()))
.is_some() =>
{
errors.push(Diagnostic::error(
"C002",
"duplicate Job database binding",
binding.span,
));
}
Declaration::Database(_) if is_job => {}
Declaration::Function(clause) => {
let declared = matches!(clause.kind, FunctionKind::Job { .. });
if is_job != declared {
@@ -159,6 +160,7 @@ pub(super) fn register(
let FunctionKind::Job {
attempts,
timeout_ms,
long,
} = entry.kind
else {
continue;
@@ -210,6 +212,7 @@ pub(super) fn register(
};
jobs.push(Job {
function,
long,
connection: connection.clone(),
payload,
attempts,
+12 -16
View File
@@ -525,23 +525,19 @@ fn validate_model_declarations(
let mut sql = BTreeSet::new();
for declaration in &package.declarations {
match declaration {
Declaration::Database(binding) => {
if database.replace(binding.span).is_some() {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one database binding",
binding.span,
));
}
Declaration::Database(binding) if database.replace(binding.span).is_some() => {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one database binding",
binding.span,
));
}
Declaration::Seed(value) => {
if seed.replace(value.span).is_some() {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one seed block",
value.span,
));
}
Declaration::Seed(value) if seed.replace(value.span).is_some() => {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one seed block",
value.span,
));
}
Declaration::Migration(migration) => {
if !migrations.insert(&migration.name.text) {
+1 -6
View File
@@ -548,12 +548,7 @@ pub(super) fn referenced_types(ty: &Type, visit: &mut impl FnMut(usize)) {
| Type::Stream(element)
| Type::AsyncStream(element)
| Type::RowStream(element)
| Type::Related(element)
| Type::Channel(element) => {
if !matches!(ty, Type::Related(_)) {
referenced_types(element, visit)
}
}
| Type::Channel(element) => referenced_types(element, visit),
Type::Map(key, value) | Type::MapEntry(key, value) => {
referenced_types(key, visit);
referenced_types(value, visit);
+2 -2
View File
@@ -588,7 +588,7 @@ fn validate_transactions(program: &Program, summaries: &[Effects], errors: &mut
if concurrent {
errors.push(Diagnostic::error(
"C014",
"transaction functions cannot start concurrent work",
"transaction functions cannot start concurrent work or external I/O",
function.span,
));
}
@@ -669,7 +669,7 @@ fn validate_transactions(program: &Program, summaries: &[Effects], errors: &mut
for function in concurrent {
errors.push(Diagnostic::error(
"C014",
"transaction functions cannot start concurrent work",
"transaction functions cannot start concurrent work or external I/O",
program.functions[function].span,
));
}
+7
View File
@@ -117,6 +117,9 @@ impl Formatter {
if binding.anonymous {
parts.extend([self.token("public"), Doc::space()]);
}
if binding.long {
parts.extend([self.token("long"), Doc::space()]);
}
parts.extend(vec![
self.token(binding.kind.keyword()),
Doc::space(),
@@ -377,6 +380,9 @@ impl Formatter {
parts.extend([self.token("transaction"), Doc::space()]);
}
if matches!(function.kind, FunctionKind::Job { .. }) {
if matches!(function.kind, FunctionKind::Job { long: true, .. }) {
parts.extend([self.token("long"), Doc::space()]);
}
parts.extend([self.token("job"), Doc::space()]);
}
for (index, segment) in function.name.text.split('.').enumerate() {
@@ -394,6 +400,7 @@ impl Formatter {
if let FunctionKind::Job {
attempts,
timeout_ms,
..
} = function.kind
{
for (name, value) in [("retry", attempts), ("timeout", timeout_ms)] {
+3
View File
@@ -44,6 +44,7 @@ pub(crate) struct AuthHook {
#[derive(Clone, Debug)]
pub(crate) struct Job {
pub function: usize,
pub long: bool,
pub connection: crate::model::ConnectionSelector,
pub payload: Option<crate::wire::Schema>,
pub attempts: u32,
@@ -148,6 +149,7 @@ impl TestCase {
#[derive(Clone, Debug)]
pub(crate) struct ApiRoute {
pub multipart: bool,
pub long: bool,
pub method: &'static str,
pub path: String,
pub function: usize,
@@ -166,6 +168,7 @@ pub(crate) struct ApiRoute {
#[derive(Clone, Debug)]
pub(crate) struct ApiCommand {
pub name: String,
pub long: bool,
pub function: usize,
pub inputs: Vec<String>,
pub components: Vec<String>,
+1 -7
View File
@@ -292,13 +292,7 @@ impl Module<'_> {
};
let mut emitter = FunctionEmitter::new(self, instance);
let parameters = emitter.begin_generated_function(&wrapper);
let password = [
Intrinsic::CryptoPasswordHash,
Intrinsic::CryptoPasswordVerify,
]
.iter()
.any(|operation| specialize::uses_intrinsic(emitter.module.program, &target, *operation));
let transaction = !password && emitter.begin_command_transaction(&target, route.span);
let transaction = !route.long && emitter.begin_command_transaction(&target, route.span);
let arguments = app
.parameters
.iter()
+1 -1
View File
@@ -752,7 +752,7 @@ impl FunctionEmitter<'_, '_> {
args.push((ty.clone(), value));
}
let output_ty = output_type(&function.outputs);
let transactional = self.begin_command_transaction(&instance, span);
let transactional = !command.long && self.begin_command_transaction(&instance, span);
let (status, row) = self.invoke_values(&instance, &args, &output_ty, span);
self.propagate_status(&status, span, false);
let value = self.temp();
@@ -213,6 +213,7 @@ impl FunctionEmitter<'_, '_> {
.program
.api_commands
.iter()
.filter(|command| !command.long)
.map(|command| Specialization {
function: command.function,
handlers: Vec::new(),
@@ -224,7 +225,7 @@ impl FunctionEmitter<'_, '_> {
.program
.api_routes
.iter()
.filter(|route| matches!(route.method, "POST" | "PUT" | "DELETE"))
.filter(|route| !route.long && matches!(route.method, "POST" | "PUT" | "DELETE"))
.map(|route| Specialization {
function: route.function,
handlers: Vec::new(),
@@ -248,6 +249,7 @@ impl FunctionEmitter<'_, '_> {
.program
.jobs
.iter()
.filter(|job| !job.long)
.map(|job| Specialization {
function: job.function,
handlers: Vec::new(),
+2 -1
View File
@@ -188,7 +188,8 @@ impl Module<'_> {
)
})
.collect::<Vec<_>>();
let transaction = emitter.begin_command_transaction(&target, function.span);
let transaction = !emitter.module.program.jobs[index].long
&& emitter.begin_command_transaction(&target, function.span);
if specialize::suspends(emitter.module.program, &target) {
let (status, _) =
emitter.invoke_values(&target, &arguments, &Type::Unit, function.span);
+15 -2
View File
@@ -699,9 +699,22 @@ fn emit_database_setup(
for function in program
.api_routes
.iter()
.filter(|route| !route.long)
.map(|route| route.function)
.chain(program.api_commands.iter().map(|command| command.function))
.chain(program.jobs.iter().map(|job| job.function))
.chain(
program
.api_commands
.iter()
.filter(|command| !command.long)
.map(|command| command.function),
)
.chain(
program
.jobs
.iter()
.filter(|job| !job.long)
.map(|job| job.function),
)
{
if function_writes_database(program, function) {
transaction_roots.insert(Specialization {
+4 -11
View File
@@ -352,16 +352,7 @@ fn emit_handler(
function: route.function,
handlers: Vec::new(),
};
let password_crypto = specialize::uses_intrinsic(
program,
&target,
crate::intrinsic::Intrinsic::CryptoPasswordVerify,
) || specialize::uses_intrinsic(
program,
&target,
crate::intrinsic::Intrinsic::CryptoPasswordHash,
);
let transaction_database = (!password_crypto)
let transaction_database = (!route.long)
.then(|| super::transaction_database(program, route.function))
.flatten();
if let Some(database) = &transaction_database {
@@ -478,7 +469,9 @@ pub(super) fn emit_commands(program: &Program, output: &mut String) {
function: command.function,
handlers: Vec::new(),
};
let transaction_database = super::transaction_database(program, command.function);
let transaction_database = (!command.long)
.then(|| super::transaction_database(program, command.function))
.flatten();
if let Some(database) = &transaction_database {
writeln!(output, "let transaction_database = ({database}).map_err(|error| AppError::database(\"command\", error))?;\nlet transaction = transaction_database.begin().await.map_err(|error| AppError::database(\"command\", error))?;")
.expect("string formatting");
+3 -1
View File
@@ -121,7 +121,9 @@ pub(super) fn definitions(
function: *target,
handlers: Vec::new(),
};
let transaction_database = super::transaction_database(emitter.program, *target);
let transaction_database = (!job.long)
.then(|| super::transaction_database(emitter.program, *target))
.flatten();
if let Some(database) = &transaction_database {
writeln!(output, "let transaction_database = match {database} {{ Ok(database) => database, Err(_) => return Outcome::Retry }};\nlet transaction = match transaction_database.begin().await {{ Ok(value) => value, Err(_) => return Outcome::Retry }};")
.expect("string formatting");
+31 -3
View File
@@ -195,6 +195,30 @@ impl Parser {
let mut errors = Vec::new();
while !self.at(&Kind::End) {
let visibility = self.take(&Kind::Public).map(|token| token.span);
let long = if self.at_name("long")
&& self
.tokens
.get(self.cursor + 1)
.is_some_and(|token| token.kind != Kind::LeftParen)
{
Some(self.advance().span)
} else {
None
};
let long_entry = match layout.role() {
Some(crate::source::SourceRole::Api) => ["get", "post", "put", "delete", "cmd"]
.iter()
.any(|name| self.at_name(name)),
Some(crate::source::SourceRole::Job) => self.at_name("job"),
_ => false,
};
if let Some(span) = long.filter(|_| !long_entry) {
errors.push(Diagnostic::error(
"P001",
"'long' is allowed only before an HTTP, CMD, or Job entry; place 'public' before 'long'",
span,
));
}
let anonymous_http = layout.role() == Some(crate::source::SourceRole::Api)
&& ["get", "post", "put", "delete"]
.iter()
@@ -264,8 +288,9 @@ impl Parser {
FunctionKind::Job {
attempts: 0,
timeout_ms: 0,
long: long.is_some(),
},
Some(start),
Some(long.unwrap_or(start)),
)
.map(Declaration::Function)
} else if self.at_name("transaction") {
@@ -280,7 +305,8 @@ impl Parser {
Ok(mut declaration) => {
if let Declaration::Api(binding) = &mut declaration {
binding.anonymous = visibility.is_some() && anonymous_http;
if let Some(start) = visibility {
binding.long = long.is_some();
if let Some(start) = visibility.or(long) {
binding.span = start.through(binding.span);
}
}
@@ -409,6 +435,7 @@ impl Parser {
self.line_end(&Kind::End)?;
Ok(Declaration::Api(ApiBinding {
anonymous: false,
long: false,
kind,
action,
target,
@@ -925,13 +952,14 @@ impl Parser {
let inputs = self.separated(Kind::RightParen, Self::input)?;
self.expect(&Kind::LeftParen, "the named output list '('")?;
let outputs = self.separated(Kind::RightParen, Self::field)?;
if matches!(kind, FunctionKind::Job { .. }) {
if let FunctionKind::Job { long, .. } = kind {
let attempts = self.job_policy("retry", 100)?;
let timeout_ms =
self.job_policy("timeout", dever_runtime::config::MAX_JOB_TIMEOUT_MS)?;
kind = FunctionKind::Job {
attempts,
timeout_ms,
long,
};
}
let (pure, recovery) = self.function_contracts()?;
+12 -5
View File
@@ -172,9 +172,6 @@ pub(crate) fn uses_intrinsic(
})
}
/// Finds a database write reachable without first crossing an explicit
/// transaction function. Password crypto APIs use this to keep slow work out
/// of SQLite transactions while preserving a short, explicit write boundary.
pub(crate) fn write_outside_transaction(
program: &crate::hir::Program,
root: &Specialization,
@@ -237,7 +234,7 @@ pub(crate) fn function_starts_transaction_unsafe_work(
program: &crate::hir::Program,
function: usize,
) -> bool {
let mut unsafe_work = false;
let mut unsafe_work = program.functions[function].external.is_some();
crate::check::visit(&program.functions[function], |expression| {
unsafe_work |= match &expression.kind {
ExpressionKind::RunCall { .. }
@@ -259,7 +256,17 @@ pub(crate) fn function_starts_transaction_unsafe_work(
}
ExpressionKind::Intrinsic { operation, .. } => {
operation.is_async()
&& !matches!(operation, crate::intrinsic::Intrinsic::UploadStore)
|| matches!(
operation,
crate::intrinsic::Intrinsic::CryptoPasswordHash
| crate::intrinsic::Intrinsic::CryptoPasswordVerify
| crate::intrinsic::Intrinsic::FileOpen
| crate::intrinsic::Intrinsic::TimeSleep
| crate::intrinsic::Intrinsic::FileCreate
| crate::intrinsic::Intrinsic::FileRead
| crate::intrinsic::Intrinsic::FileWrite
| crate::intrinsic::Intrinsic::FileChunks
)
}
ExpressionKind::Collection {
operation,
+6 -1
View File
@@ -106,6 +106,7 @@ impl ApiKind {
#[derive(Clone, Debug, PartialEq)]
pub struct ApiBinding {
pub anonymous: bool,
pub long: bool,
pub kind: ApiKind,
pub action: Name,
pub target: Path,
@@ -277,7 +278,11 @@ pub struct FunctionClause {
pub enum FunctionKind {
Ordinary,
Transaction,
Job { attempts: u32, timeout_ms: u32 },
Job {
attempts: u32,
timeout_ms: u32,
long: bool,
},
}
#[derive(Clone, Debug, PartialEq)]