aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--lib/constraint.ml191
-rw-r--r--lib/management/database.ml23
-rw-r--r--lib/manipulation.ml294
3 files changed, 437 insertions, 71 deletions
diff --git a/lib/constraint.ml b/lib/constraint.ml
index 0ba7d4f..527a7dc 100644
--- a/lib/constraint.ml
+++ b/lib/constraint.ml
@@ -243,25 +243,18 @@ and extend_tuple (tuple : Tuple.materialized)
(variable : attr_name)
(row : (attr_name * Conventions.AbstractValue.t) list)
: Tuple.materialized =
- (* TODO: Provenance for tuples at the relation level.
- Attributes from quantifier rows are namespaced by the variable name
- (e.g. "d.dept_id") to avoid silent overwriting of same-named attributes
- from the base tuple or other quantifiers. Without namespacing, the
- following FK constraint silently passes for an invalid insertion:
- - Employee { emp_id=1, dept_id=99 } is being inserted.
- - Constraint: Exists d in Department, MemberOf Department (dept_id = Var "dept_id").
- - Department contains only { dept_id=1, dept_name="Engineering" }.
- - extend_tuple overwrites dept_id=99 with dept_id=1 from the Department row.
- - Var "dept_id" resolves to 1, check_membership succeeds trivially.
- - The insertion is accepted even though dept_id=99 does not exist.
- Namespacing is a syntactic encoding of provenance: the variable prefix
- records which quantifier scope each value came from. A more principled
- solution would attach provenance metadata directly to each attribute in
- the relation, making the origin of every value explicit and queryable
- without relying on name conventions. This would also eliminate the
- ambiguity that arises when two quantifiers range over relations sharing
- attribute names — currently resolved by namespacing, but ideally enforced
- structurally by the type system or relation schema. *)
+ (* Quantifier attributes are namespaced by the variable name (for example "d.dept_id")
+ so they cannot silently overwrite same-named attributes from the base tuple
+ or from other quantifiers. Without such namespacing, a constraint like
+ Exists d in Department, MemberOf Department (dept_id = Var "dept_id")
+ would potentially overwrite the base tuple's dept_id with the Department row's dept_id,
+ causing Var "dept_id" to resolve to the wrong value and accept invalid inserts.
+ Constraints reference quantifier attributes as Var "variable.attr"
+ (for example Var "d.dept_id") and base tuple attributes as Var "attr" (like Var "dept_id").
+ This convention is the prerequisite for universal variable substitution
+ (see docs/incremental_constraint_checking.org, specifically Technique 2).
+ A better long-term solution would attach provenance metadata directly
+ to each attribute rather than relying on name conventions, but we don't have that yet. *)
let extra_attrs =
List.fold_left
(fun acc (k, v) ->
@@ -287,6 +280,166 @@ let evaluate_named (ctx : eval_context) (tuple : Tuple.materialized)
| [] -> Ok true
| fs -> Error (ConstraintFailures fs)
+(** Polarity analysis: determines whether each relation name appears in
+ a positive (must-exist) or negative (must-not-exist) position within
+ a constraint tree. Used for cascade checking: when a tuple is deleted,
+ only relations with Positive or Both polarity need re-validation. *)
+
+type polarity = Positive | Negative | Both
+
+let merge_polarity a b =
+ match a, b with
+ | Positive, Positive -> Positive
+ | Negative, Negative -> Negative
+ | _ -> Both
+
+let flip_polarity = function
+ | Positive -> Negative
+ | Negative -> Positive
+ | Both -> Both
+
+let merge_polarity_maps m1 m2 =
+ List.fold_left (fun acc (name, pol) ->
+ match List.assoc_opt name acc with
+ | None -> (name, pol) :: acc
+ | Some prev ->
+ (name, merge_polarity prev pol)
+ :: List.filter (fun (n, _) -> n <> name) acc)
+ m1 m2
+
+let rec polarity_of ?(neg = false) (c : t) : (relation_name * polarity) list =
+ let pos p = if neg then flip_polarity p else p in
+ match c with
+ | MemberOf { target; _ } ->
+ [(target, pos Positive)]
+ | Not { body; _ } ->
+ polarity_of ~neg:(not neg) body
+ | And cs | Or cs ->
+ List.fold_left (fun acc c ->
+ merge_polarity_maps acc (polarity_of ~neg c)) [] cs
+ | Exists { quantifier; body; _ } ->
+ merge_polarity_maps
+ [(quantifier, pos Positive)]
+ (polarity_of ~neg body)
+ | Forall { quantifier; body; _ } ->
+ merge_polarity_maps
+ [(quantifier, pos Negative)]
+ (polarity_of ~neg body)
+
+(** Constraint timing: immediate (checked on every mutation) or deferred
+ (checked at transaction commit). *)
+type timing = Immediate | Deferred
+
+(** Given a constraint, a deleted relation name [dep_rel], and the deleted
+ tuple's attributes, return a filter: [(attr_name, value)] pairs that
+ narrow which constrained-relation tuples could be affected.
+
+ For a Var binding like [dept_id = Var "dept_id"] targeting [dep_rel],
+ the deleted tuple's [dept_id] value becomes the filter: only constrained
+ tuples with that exact [dept_id] need re-checking.
+
+ Returns [] if no narrowing is possible (Const bindings, unrelated rel). *)
+let rec focused_filter (c : t) (dep_rel : relation_name)
+ (deleted : (attr_name * Conventions.AbstractValue.t) list)
+ : (attr_name * Conventions.AbstractValue.t) list =
+ match c with
+ | MemberOf { target; binding } when target = dep_rel ->
+ BindingMap.fold (fun _target_attr expr acc ->
+ match expr with
+ | Var src_attr ->
+ (match List.assoc_opt src_attr deleted with
+ | Some v -> (src_attr, v) :: acc
+ | None -> acc)
+ | Const _ -> acc)
+ binding []
+ | MemberOf _ -> []
+ | Not { body; _ } -> focused_filter body dep_rel deleted
+ | And cs | Or cs ->
+ List.concat_map (fun c -> focused_filter c dep_rel deleted) cs
+ | Exists { body; _ } | Forall { body; _ } ->
+ focused_filter body dep_rel deleted
+
+(** Extract Const binding values from a constraint for a given [dep_rel].
+
+ These are fixed-value preconditions: if the deleted tuple doesn't have
+ these exact values, the constraint cannot be violated by the deletion.
+ The cascade checker uses this to bail out early. *)
+let rec trigger_constants (c : t) (dep_rel : relation_name)
+ : (attr_name * Conventions.AbstractValue.t) list =
+ match c with
+ | MemberOf { target; binding } when target = dep_rel ->
+ BindingMap.fold (fun target_attr expr acc ->
+ match expr with
+ | Const v -> (target_attr, v) :: acc
+ | Var _ -> acc)
+ binding []
+ | MemberOf _ -> []
+ | Not { body; _ } -> trigger_constants body dep_rel
+ | And cs | Or cs ->
+ List.concat_map (fun c -> trigger_constants c dep_rel) cs
+ | Exists { body; _ } | Forall { body; _ } ->
+ trigger_constants body dep_rel
+
+(** Substitute transition tuple values into a constraint for universal variable
+ substitution. When [dep_rel] is being mutated (deleted from or inserted
+ into), any [Exists] or [Forall] that quantifies over [dep_rel] has its body
+ rewritten: occurrences of [Var "variable.attr"] are replaced by
+ [Const value] using the transition tuple's attribute values.
+
+ The convention that quantifier attributes are namespaced as [variable.attr]
+ (established by [extend_tuple]) means base tuple [Var] references like
+ [Var "dept_id"] are never substituted, only [Var "d.dept_id"] would match
+ a quantifier variable [d] over a deleted [Department] row.
+
+ This transforms the recheck from a full relation evaluation into a targeted
+ expression that tests only the rows affected by the transition tuple.
+ See Technique 2 in docs/incremental_constraint_checking.org. *)
+let substitute_transition
+ (c : t)
+ (dep_rel : relation_name)
+ (transition : (attr_name * Conventions.AbstractValue.t) list)
+ : t =
+ let apply_subs subs binding =
+ BindingMap.map (function
+ | Var v -> (match List.assoc_opt v subs with
+ | Some value -> Const value
+ | None -> Var v)
+ | Const _ as expr -> expr)
+ binding
+ in
+ let rec sub_body subs = function
+ | MemberOf { target; binding } ->
+ MemberOf { target; binding = apply_subs subs binding }
+ | Not { body; universe } -> Not { body = sub_body subs body; universe }
+ | And cs -> And (List.map (sub_body subs) cs)
+ | Or cs -> Or (List.map (sub_body subs) cs)
+ | Exists { variable; quantifier; body } ->
+ Exists { variable; quantifier; body = sub_body subs body }
+ | Forall { variable; quantifier; body } ->
+ Forall { variable; quantifier; body = sub_body subs body }
+ in
+ let rec go = function
+ | MemberOf _ as leaf -> leaf
+ | Not { body; universe } -> Not { body = go body; universe }
+ | And cs -> And (List.map go cs)
+ | Or cs -> Or (List.map go cs)
+ | Exists { variable; quantifier; body } ->
+ let body' =
+ if quantifier = dep_rel then
+ sub_body (List.map (fun (a, v) -> (variable ^ "." ^ a, v)) transition) body
+ else go body
+ in
+ Exists { variable; quantifier; body = body' }
+ | Forall { variable; quantifier; body } ->
+ let body' =
+ if quantifier = dep_rel then
+ sub_body (List.map (fun (a, v) -> (variable ^ "." ^ a, v)) transition) body
+ else go body
+ in
+ Forall { variable; quantifier; body = body' }
+ in
+ go c
+
let make_comparison_binding ~left ~right =
BindingMap.empty
|> BindingMap.add "left" left
diff --git a/lib/management/database.ml b/lib/management/database.ml
index 2dc0ae6..c4c6dc3 100644
--- a/lib/management/database.ml
+++ b/lib/management/database.ml
@@ -8,6 +8,12 @@
module RelationMap = Map.Make(String)
+type deferred_entry = {
+ relation_name : string;
+ constraint_name : string;
+ body : Constraint.t;
+}
+
type t = {
hash : Conventions.Hash.t;
name : Conventions.Name.t;
@@ -16,6 +22,7 @@ type t = {
domains : Domain.t RelationMap.t;
history : Conventions.Hash.t list;
timestamp : float;
+ deferred : deferred_entry list;
}
let empty ~name = {
@@ -26,6 +33,7 @@ let empty ~name = {
domains = RelationMap.empty;
history = [];
timestamp = Unix.gettimeofday ();
+ deferred = [];
}
let compute_hash db =
@@ -33,15 +41,26 @@ let compute_hash db =
| Some root -> root
| None -> Conventions.Hash.hash_text db.name
+let max_history = 128
+
+let rec take_at_most n = function
+ | [] -> []
+ | _ when n = 0 -> []
+ | x :: xs -> x :: take_at_most (n - 1) xs
+
let update_state db ~relations ~tree =
let new_hash =
match Merkle.root_hash tree with
| Some root -> root
| None -> Conventions.Hash.hash_text db.name
in
- let history = if db.hash = "" then db.history else db.hash :: db.history in
+ let history =
+ if db.hash = "" then db.history
+ else take_at_most max_history (db.hash :: db.history)
+ in
{ hash = new_hash; name = db.name; tree; relations;
- domains = db.domains; history; timestamp = Unix.gettimeofday () }
+ domains = db.domains; history; timestamp = Unix.gettimeofday ();
+ deferred = db.deferred }
let get_relation db name = RelationMap.find_opt name db.relations
diff --git a/lib/manipulation.ml b/lib/manipulation.ml
index be77c02..58e6cbb 100644
--- a/lib/manipulation.ml
+++ b/lib/manipulation.ml
@@ -261,6 +261,7 @@ module Make (Storage : Management.Physical.S) = struct
domains = Management.Database.RelationMap.empty;
history = stored.history;
timestamp = stored.timestamp;
+ deferred = [];
})
(* Relation Operations - forward declarations needed by catalog helpers
@@ -314,6 +315,123 @@ module Make (Storage : Management.Physical.S) = struct
in
store_all [] attrs
+ (** Load a tuple from storage by its hash *)
+ let load_tuple
+ (storage : storage)
+ (tuple_hash : Conventions.Hash.t)
+ : (Tuple.materialized option, error) Result.t =
+ match Storage.load_raw storage tuple_hash with
+ | Error _ -> Error (StorageError "Failed to load tuple")
+ | Ok None -> Ok None
+ | Ok (Some tuple_bytes) ->
+ let stored = Storable.Tuple.of_bytes tuple_bytes in
+ let rec load_attrs acc = function
+ | [] -> Ok (List.rev acc)
+ | (name, attr_hash) :: rest ->
+ match Storage.load_raw storage attr_hash with
+ | Error _ -> Error (StorageError ("Failed to load attribute: " ^ name))
+ | Ok None -> Error (StorageError ("Attribute not found: " ^ name))
+ | Ok (Some value_bytes) ->
+ let value : Conventions.AbstractValue.t = Marshal.from_bytes value_bytes 0 in
+ let attr : Attribute.materialized = { value } in
+ load_attrs ((name, attr) :: acc) rest
+ in
+ match load_attrs [] stored.attributes with
+ | Error e -> Error e
+ | Ok attrs ->
+ let attributes = List.fold_left
+ (fun m (k, v) -> Tuple.AttributeMap.add k v m)
+ Tuple.AttributeMap.empty attrs
+ in
+ Ok (Some { Tuple.relation = stored.relation; attributes })
+
+ (** Load multiple tuples from storage *)
+ let load_tuples
+ (storage : storage)
+ (hashes : Conventions.Hash.t list)
+ : (Tuple.materialized list, error) Result.t =
+ let rec load_all acc = function
+ | [] -> Ok (List.rev acc)
+ | hash :: rest ->
+ match load_tuple storage hash with
+ | Error e -> Error e
+ | Ok None -> Error (TupleNotFound hash)
+ | Ok (Some tuple) -> load_all (tuple :: acc) rest
+ in
+ load_all [] hashes
+
+ (** Scan every relation in [db] for constraints that reference [dep_rel] with
+ a polarity matched by [polarity_triggers], then re-evaluate those
+ constraints against the affected tuples in each constrained relation.
+
+ [transition] is the inserted or deleted tuple's attributes.
+ [polarity_triggers] returns true for the polarities that the current
+ mutation type can violate:
+ - DELETE: Positive | Both
+ - INSERT: Negative | Both
+
+ Returns [Ok ()] when all cascade checks pass. *)
+ let check_cascade_constraints
+ (storage : storage)
+ (db : Management.Database.t)
+ ~(dep_rel : string)
+ ~(transition : (string * Conventions.AbstractValue.t) list)
+ ~(polarity_triggers : Constraint.polarity -> bool)
+ ~(violation_msg : string -> string -> string)
+ : unit result =
+ let ctx = build_eval_context storage db in
+ Management.Database.RelationMap.fold
+ (fun constrained_name constrained_rel acc ->
+ match acc with Error _ -> acc | Ok () ->
+ let is_deferred cname =
+ List.exists (fun (d : Management.Database.deferred_entry) ->
+ d.constraint_name = cname && d.relation_name = constrained_name)
+ db.deferred
+ in
+ match constrained_rel.Relation.constraints with
+ | None | Some [] -> Ok ()
+ | Some named_constraints ->
+ List.fold_left (fun acc (cname, cbody) ->
+ match acc with Error _ -> acc | Ok () ->
+ if is_deferred cname then Ok ()
+ else
+ let pols = Constraint.polarity_of cbody in
+ match List.assoc_opt dep_rel pols with
+ | None -> Ok ()
+ | Some pol when not (polarity_triggers pol) -> Ok ()
+ | Some _ ->
+ let filter = Constraint.focused_filter cbody dep_rel transition in
+ let hashes = match constrained_rel.tree with
+ | None -> [] | Some t -> Merkle.keys t
+ in
+ let rec check_tuples = function
+ | [] -> Ok ()
+ | h :: rest ->
+ (match load_tuple storage h with
+ | Error _ | Ok None -> check_tuples rest
+ | Ok (Some tup) ->
+ let needs_recheck =
+ filter = [] ||
+ List.for_all (fun (attr, fval) ->
+ match Tuple.AttributeMap.find_opt attr tup.attributes with
+ | Some a -> Stdlib.(=) a.Attribute.value fval
+ | None -> false) filter
+ in
+ if not needs_recheck then check_tuples rest
+ else
+ let cbody' =
+ Constraint.substitute_transition cbody dep_rel transition
+ in
+ match Constraint.evaluate_named ctx tup [(cname, cbody')] with
+ | Ok true -> check_tuples rest
+ | Ok false | Error _ ->
+ Error (ConstraintViolation
+ (violation_msg cname constrained_name)))
+ in
+ check_tuples hashes)
+ (Ok ()) named_constraints)
+ db.relations (Ok ())
+
(** Insert a tuple into a relation, storing it in the backend *)
let create_tuple
(storage : storage)
@@ -398,6 +516,26 @@ module Make (Storage : Management.Physical.S) = struct
(* Update database with the new relation *)
let new_db = Management.Database.update_relation db ~relation:new_relation in
+ (* Cascade constraint check (INSERT) *)
+ let inserted_attrs =
+ Tuple.AttributeMap.bindings tuple.attributes
+ |> List.map (fun (k, attr) -> (k, attr.Attribute.value))
+ in
+ let cascade_ok =
+ check_cascade_constraints storage new_db
+ ~dep_rel:name
+ ~transition:inserted_attrs
+ ~polarity_triggers:(function
+ | Constraint.Negative | Constraint.Both -> true
+ | Constraint.Positive -> false)
+ ~violation_msg:(fun cname crel ->
+ Printf.sprintf
+ "cascade: inserting into %s violates constraint %s on %s"
+ name cname crel)
+ in
+ match cascade_ok with
+ | Error e -> Error e
+ | Ok () ->
(* Persist relation and database state *)
match store_relation storage new_relation with
| Error e -> Error e
@@ -423,53 +561,6 @@ module Make (Storage : Management.Physical.S) = struct
in
insert_all db relation [] tuples
- (** Load a tuple from storage by its hash *)
- let load_tuple
- (storage : storage)
- (tuple_hash : Conventions.Hash.t)
- : (Tuple.materialized option, error) Result.t =
- match Storage.load_raw storage tuple_hash with
- | Error _ -> Error (StorageError "Failed to load tuple")
- | Ok None -> Ok None
- | Ok (Some tuple_bytes) ->
- let stored = Storable.Tuple.of_bytes tuple_bytes in
- (* Load each attribute value *)
- let rec load_attrs acc = function
- | [] -> Ok (List.rev acc)
- | (name, attr_hash) :: rest ->
- match Storage.load_raw storage attr_hash with
- | Error _ -> Error (StorageError ("Failed to load attribute: " ^ name))
- | Ok None -> Error (StorageError ("Attribute not found: " ^ name))
- | Ok (Some value_bytes) ->
- let value : Conventions.AbstractValue.t = Marshal.from_bytes value_bytes 0 in
- let attr : Attribute.materialized = { value } in
- load_attrs ((name, attr) :: acc) rest
- in
- match load_attrs [] stored.attributes with
- | Error e -> Error e
- | Ok attrs ->
- let attributes = List.fold_left
- (fun m (k, v) -> Tuple.AttributeMap.add k v m)
- Tuple.AttributeMap.empty
- attrs
- in
- Ok (Some { Tuple.relation = stored.relation; attributes })
-
- (** Load multiple tuples from storage *)
- let load_tuples
- (storage : storage)
- (hashes : Conventions.Hash.t list)
- : (Tuple.materialized list, error) Result.t =
- let rec load_all acc = function
- | [] -> Ok (List.rev acc)
- | hash :: rest ->
- match load_tuple storage hash with
- | Error e -> Error e
- | Ok None -> Error (TupleNotFound hash)
- | Ok (Some tuple) -> load_all (tuple :: acc) rest
- in
- load_all [] hashes
-
(** Remove a tuple from a relation by its hash *)
let retract_tuple
(storage : storage)
@@ -512,15 +603,37 @@ module Make (Storage : Management.Physical.S) = struct
(* Update database *)
let new_db = Management.Database.update_relation db ~relation:new_relation in
+ (* Cascade constraint check (DELETE) *)
+ let cascade_ok =
+ match load_tuple storage tuple_hash with
+ | Error _ | Ok None -> Ok () (* can't load the deleted tuple, so just skip *)
+ | Ok (Some deleted_tuple) ->
+ let deleted_attrs =
+ Tuple.AttributeMap.bindings deleted_tuple.attributes
+ |> List.map (fun (k, attr) -> (k, attr.Attribute.value))
+ in
+ check_cascade_constraints storage new_db
+ ~dep_rel:name
+ ~transition:deleted_attrs
+ ~polarity_triggers:(function
+ | Constraint.Positive | Constraint.Both -> true
+ | Constraint.Negative -> false)
+ ~violation_msg:(fun cname crel ->
+ Printf.sprintf "cascade: deleting from %s violates constraint %s on %s"
+ name cname crel)
+ in
+ (match cascade_ok with
+ | Error e -> Error e
+ | Ok () ->
(* Persist relation and database state *)
match store_relation storage new_relation with
| Error e -> Error e
| Ok () ->
match store_database storage new_db with
| Error e -> Error e
- | Ok () -> Ok (new_db, new_relation)
+ | Ok () -> Ok (new_db, new_relation))
- (*
+ (*
System Catalog Maintenance
*)
@@ -858,7 +971,7 @@ module Make (Storage : Management.Physical.S) = struct
| None -> constraints
| Some existing -> Constraint.merge existing constraints
in
- (* Don't bake constraint evaluation into membership_criteria —
+ (* Do not bake constraint evaluation into membership_criteria.
create_tuple evaluates constraints with the current db snapshot.
membership_criteria stays for schema/domain validation only. *)
let new_relation =
@@ -942,6 +1055,87 @@ module Make (Storage : Management.Physical.S) = struct
match relation.tree with
| None -> false
| Some tree -> Merkle.member tuple_hash tree
+
+ (** Attach a named constraint with timing metadata.
+ Immediate constraints are added to the relation's constraint list.
+ Deferred constraints are recorded in the database's deferred list
+ and also attached to the relation (for schema visibility). *)
+ let attach_constraint
+ (storage : storage)
+ (db : Management.Database.t)
+ ~(constraint_name : string)
+ ~(relation_name : string)
+ ~(body : Constraint.t)
+ ~(timing : Constraint.timing)
+ : (Management.Database.t, error) Result.t =
+ match update_relation_constraints storage db ~relation_name
+ ~constraints:[(constraint_name, body)] with
+ | Error e -> Error e
+ | Ok (db, _) ->
+ match timing with
+ | Constraint.Immediate -> Ok db
+ | Constraint.Deferred ->
+ let entry : Management.Database.deferred_entry = {
+ relation_name; constraint_name; body;
+ } in
+ Ok { db with deferred = entry :: db.deferred }
+
+ (** Check all deferred constraints against the current database state.
+ Returns [Ok ()] if all pass, or an error on the first violation. *)
+ let check_deferred_constraints
+ (storage : storage)
+ (db : Management.Database.t)
+ : (unit, error) Result.t =
+ List.fold_left (fun acc (entry : Management.Database.deferred_entry) ->
+ match acc with Error _ -> acc | Ok () ->
+ match Management.Database.get_relation db entry.relation_name with
+ | None -> Ok () (* relation gone, nothing to check *)
+ | Some rel ->
+ let ctx = build_eval_context storage db in
+ let hashes = match rel.tree with
+ | None -> [] | Some t -> Merkle.keys t
+ in
+ let rec check = function
+ | [] -> Ok ()
+ | h :: rest ->
+ match load_tuple storage h with
+ | Error _ | Ok None -> check rest
+ | Ok (Some tup) ->
+ match Constraint.evaluate_named ctx tup
+ [(entry.constraint_name, entry.body)] with
+ | Ok true -> check rest
+ | Ok false | Error _ ->
+ Error (ConstraintViolation
+ (Printf.sprintf "deferred constraint %s on %s violated"
+ entry.constraint_name entry.relation_name))
+ in
+ check hashes)
+ (Ok ()) db.deferred
+
+ (** Complete a mutation sequence by evaluating all deferred constraints
+ against the current database state.
+
+ On success, returns the database with the deferred list cleared.
+ Subsequent mutations start a fresh deferral window. On failure,
+ returns the constraint violation error and leaves the database
+ unchanged so the caller can inspect or roll back.
+
+ Typical usage:
+ let db = create_tuple storage db rel t1 in
+ let db = retract_tuple storage db rel t2 in
+ let db = commit storage db in (* deferred checks run here *)
+ ... *)
+ let commit
+ (storage : storage)
+ (db : Management.Database.t)
+ : (Management.Database.t, error) Result.t =
+ match check_deferred_constraints storage db with
+ | Error e -> Error e
+ | Ok () ->
+ let db' = { db with deferred = [] } in
+ match store_database storage db' with
+ | Error e -> Error e
+ | Ok () -> Ok db'
end
(** Default instance using in-memory storage for convenience/testing *)